diff --git a/.dockerignore b/.dockerignore
index 3f674f978db..602ffade5d3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,9 +1,11 @@
*
!invokeai
!pyproject.toml
+!uv.lock
!docker/docker-entrypoint.sh
!LICENSE
+**/dist
**/node_modules
**/__pycache__
-**/*.egg-info
\ No newline at end of file
+**/*.egg-info
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index c6b833cf589..5c04dc964ef 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -1,2 +1,5 @@
b3dccfaeb636599c02effc377cdd8a87d658256c
218b6d0546b990fc449c876fb99f44b50c4daa35
+182580ff6970caed400be178c5b888514b75d7f2
+8e9d5c1187b0d36da80571ce4c8ba9b3a37b6c46
+99aac5870e1092b182e6c5f21abcaab6936a4ad1
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
index 4c9fc0120e5..6cf175e7c5a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,4 +2,6 @@
# Only affects text files and ignores other file types.
# For more info see: https://www.aleksandrhovhannisyan.com/blog/crlf-vs-lf-normalizing-line-endings-in-git/
* text=auto
-docker/** text eol=lf
\ No newline at end of file
+docker/** text eol=lf
+tests/test_model_probe/stripped_models/** filter=lfs diff=lfs merge=lfs -text
+tests/model_identification/stripped_models/** filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/AGENTS.md b/.github/AGENTS.md
new file mode 100644
index 00000000000..9f701a7e33d
--- /dev/null
+++ b/.github/AGENTS.md
@@ -0,0 +1,24 @@
+# Agent Instructions
+
+## Package Management
+
+This project uses **pnpm** exclusively for package management in the frontend (`invokeai/frontend/web/`).
+
+- ✅ Use `pnpm` commands (e.g., `pnpm install`, `pnpm run`)
+- ❌ Never use `npm` or `yarn` commands
+- ❌ Never suggest creating or using `package-lock.json` or `yarn.lock`
+- ✅ The lock file is `pnpm-lock.yaml`
+
+Use the following pnpm commands for typical operations:
+
+- pnpm -C invokeai/frontend/web install
+- pnpm -C invokeai/frontend/web build
+- pnpm -C invokeai/frontend/web lint:tsc
+- pnpm -C invokeai/frontend/web lint:dpdm
+- pnpm -C invokeai/frontend/web lint:eslint
+- pnpm -C invokeai/frontend/web lint:prettier
+
+## Project Structure
+
+- Backend: Python in `invokeai/`
+- Frontend: TypeScript/React in `invokeai/frontend/web/` (uses pnpm)
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index b979196cc1b..f62b8c90f11 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,32 +1,30 @@
# continuous integration
-/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr
+/.github/workflows/ @lstein @blessedcoolant
-# documentation
-/docs/ @lstein @blessedcoolant @hipsterusername @Millu
-/mkdocs.yml @lstein @blessedcoolant @hipsterusername @Millu
+# documentation - anyone with write privileges can review
+/docs/
# nodes
-/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername
+/invokeai/app/ @blessedcoolant @lstein @dunkeroni @JPPhoto
# installation and configuration
-/pyproject.toml @lstein @blessedcoolant @hipsterusername
-/docker/ @lstein @blessedcoolant @hipsterusername @ebr
-/scripts/ @ebr @lstein @hipsterusername
-/installer/ @lstein @ebr @hipsterusername
-/invokeai/assets @lstein @ebr @hipsterusername
-/invokeai/configs @lstein @hipsterusername
-/invokeai/version @lstein @blessedcoolant @hipsterusername
+/pyproject.toml @lstein @blessedcoolant
+/docker/ @lstein @blessedcoolant
+/scripts/ @lstein @blessedcoolant
+/installer/ @lstein @blessedcoolant
+/invokeai/assets @lstein @blessedcoolant
+/invokeai/configs @lstein @blessedcoolant
+/invokeai/version @lstein @blessedcoolant
# web ui
-/invokeai/frontend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername
-/invokeai/backend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername
+/invokeai/frontend @blessedcoolant @lstein @dunkeroni
# generation, model management, postprocessing
-/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername
+/invokeai/backend @lstein @blessedcoolant @dunkeroni @JPPhoto @Pfannkuchensack
# front ends
-/invokeai/frontend/CLI @lstein @hipsterusername
-/invokeai/frontend/install @lstein @ebr @hipsterusername
-/invokeai/frontend/merge @lstein @blessedcoolant @hipsterusername
-/invokeai/frontend/training @lstein @blessedcoolant @hipsterusername
-/invokeai/frontend/web @psychedelicious @blessedcoolant @maryhipp @hipsterusername
+/invokeai/frontend/CLI @lstein
+/invokeai/frontend/install @lstein
+/invokeai/frontend/merge @lstein @blessedcoolant
+/invokeai/frontend/training @lstein @blessedcoolant
+/invokeai/frontend/web @blessedcoolant @lstein @dunkeroni @Pfannkuchensack
diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
index 26e1579f73a..d49271b7d45 100644
--- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
+++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml
@@ -21,6 +21,20 @@ body:
- label: I have searched the existing issues
required: true
+ - type: dropdown
+ id: install_method
+ attributes:
+ label: Install method
+ description: How did you install Invoke?
+ multiple: false
+ options:
+ - "Invoke's Launcher"
+ - 'Stability Matrix'
+ - 'Pinokio'
+ - 'Manual'
+ validations:
+ required: true
+
- type: markdown
attributes:
value: __Describe your environment__
@@ -76,8 +90,8 @@ body:
attributes:
label: Version number
description: |
- The version of Invoke you have installed. If it is not the latest version, please update and try again to confirm the issue still exists. If you are testing main, please include the commit hash instead.
- placeholder: ex. 3.6.1
+ The version of Invoke you have installed. If it is not the [latest version](https://github.com/invoke-ai/InvokeAI/releases/latest), please update and try again to confirm the issue still exists. If you are testing main, please include the commit hash instead.
+ placeholder: ex. v6.0.2
validations:
required: true
@@ -85,17 +99,17 @@ body:
id: browser-version
attributes:
label: Browser
- description: Your web browser and version.
+ description: Your web browser and version, if you do not use the Launcher's provided GUI.
placeholder: ex. Firefox 123.0b3
validations:
- required: true
+ required: false
- type: textarea
id: python-deps
attributes:
- label: Python dependencies
+ label: System Information
description: |
- If the problem occurred during image generation, click the gear icon at the bottom left corner, click "About", click the copy button and then paste here.
+ Click the gear icon at the bottom left corner, then click "About". Click the copy button and then paste here.
validations:
required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 306483bfaaf..6febd0917db 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.github/actions/install-frontend-deps/action.yml b/.github/actions/install-frontend-deps/action.yml
index 32b49872494..1e6d3e6be80 100644
--- a/.github/actions/install-frontend-deps/action.yml
+++ b/.github/actions/install-frontend-deps/action.yml
@@ -3,15 +3,15 @@ description: Installs frontend dependencies with pnpm, with caching
runs:
using: 'composite'
steps:
- - name: setup node 18
- uses: actions/setup-node@v4
+ - name: setup node 22
+ uses: actions/setup-node@v6
with:
- node-version: '18'
+ node-version: '22'
- name: setup pnpm
- uses: pnpm/action-setup@v2
+ uses: pnpm/action-setup@v6
with:
- version: 8
+ version: 10
run_install: false
- name: get pnpm store directory
@@ -20,7 +20,7 @@ runs:
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: setup cache
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 6c8fee470e9..84633de6ce1 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -8,7 +8,7 @@
## QA Instructions
-
+
## Merge Plan
@@ -18,4 +18,6 @@
- [ ] _The PR has a short but descriptive title, suitable for a changelog_
- [ ] _Tests added / updated (if applicable)_
+- [ ] _❗Changes to a redux slice have a corresponding migration_
- [ ] _Documentation added / updated (if applicable)_
+- [ ] _Updated `What's New` copy (if doing a release after this PR)_
diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml
index a8bfaa540c1..b5bd8637acd 100644
--- a/.github/workflows/build-container.yml
+++ b/.github/workflows/build-container.yml
@@ -13,6 +13,12 @@ on:
tags:
- 'v*.*.*'
workflow_dispatch:
+ inputs:
+ push-to-registry:
+ description: Push the built image to the container registry
+ required: false
+ type: boolean
+ default: false
permissions:
contents: write
@@ -39,27 +45,36 @@ jobs:
steps:
- name: Free up more disk space on the runner
# https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
+ # the /mnt dir has 70GBs of free space
+ # /dev/sda1 74G 28K 70G 1% /mnt
+ # According to some online posts the /mnt is not always there, so checking before setting docker to use it
run: |
echo "----- Free space before cleanup"
df -h
sudo rm -rf /usr/share/dotnet
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- sudo swapoff /mnt/swapfile
- sudo rm -rf /mnt/swapfile
+ if [ -f /mnt/swapfile ]; then
+ sudo swapoff /mnt/swapfile
+ sudo rm -rf /mnt/swapfile
+ fi
+ if [ -d /mnt ]; then
+ sudo chmod -R 777 /mnt
+ echo '{"data-root": "/mnt/docker-root"}' | sudo tee /etc/docker/daemon.json
+ sudo systemctl restart docker
+ fi
echo "----- Free space after cleanup"
df -h
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
- name: Docker meta
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
images: |
ghcr.io/${{ github.repository }}
- ${{ env.DOCKERHUB_REPOSITORY }}
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -71,50 +86,33 @@ jobs:
latest=${{ matrix.gpu-driver == 'cuda' && github.ref == 'refs/heads/main' }}
suffix=-${{ matrix.gpu-driver }},onlatest=false
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v2
-
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v4
with:
platforms: ${{ env.PLATFORMS }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
- uses: docker/login-action@v2
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- # - name: Login to Docker Hub
- # if: github.event_name != 'pull_request' && vars.DOCKERHUB_REPOSITORY != ''
- # uses: docker/login-action@v2
- # with:
- # username: ${{ secrets.DOCKERHUB_USERNAME }}
- # password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Build container
timeout-minutes: 40
id: docker_build
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
platforms: ${{ env.PLATFORMS }}
- push: ${{ github.ref == 'refs/heads/main' || github.ref_type == 'tag' }}
+ build-args: |
+ GPU_DRIVER=${{ matrix.gpu-driver }}
+ push: ${{ github.ref == 'refs/heads/main' || github.ref_type == 'tag' || github.event.inputs.push-to-registry }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- cache-from: |
- type=gha,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
- type=gha,scope=main-${{ matrix.gpu-driver }}
- cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
-
- # - name: Docker Hub Description
- # if: github.ref == 'refs/heads/main' || github.ref == 'refs/tags/*' && vars.DOCKERHUB_REPOSITORY != ''
- # uses: peter-evans/dockerhub-description@v3
- # with:
- # username: ${{ secrets.DOCKERHUB_USERNAME }}
- # password: ${{ secrets.DOCKERHUB_TOKEN }}
- # repository: ${{ vars.DOCKERHUB_REPOSITORY }}
- # short-description: ${{ github.event.repository.description }}
+ # cache-from: |
+ # type=gha,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
+ # type=gha,scope=main-${{ matrix.gpu-driver }}
+ # cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.gpu-driver }}
diff --git a/.github/workflows/build-installer.yml b/.github/workflows/build-installer.yml
deleted file mode 100644
index b517751960c..00000000000
--- a/.github/workflows/build-installer.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-# Builds and uploads the installer and python build artifacts.
-
-name: build installer
-
-on:
- workflow_dispatch:
- workflow_call:
-
-jobs:
- build-installer:
- runs-on: ubuntu-latest
- timeout-minutes: 5 # expected run time: <2 min
- steps:
- - name: checkout
- uses: actions/checkout@v4
-
- - name: setup python
- uses: actions/setup-python@v5
- with:
- python-version: '3.10'
- cache: pip
- cache-dependency-path: pyproject.toml
-
- - name: install pypa/build
- run: pip install --upgrade build
-
- - name: setup frontend
- uses: ./.github/actions/install-frontend-deps
-
- - name: create installer
- id: create_installer
- run: ./create_installer.sh
- working-directory: installer
-
- - name: upload python distribution artifact
- uses: actions/upload-artifact@v4
- with:
- name: dist
- path: ${{ steps.create_installer.outputs.DIST_PATH }}
-
- - name: upload installer artifact
- uses: actions/upload-artifact@v4
- with:
- name: installer
- path: ${{ steps.create_installer.outputs.INSTALLER_PATH }}
diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml
new file mode 100644
index 00000000000..546d1b07088
--- /dev/null
+++ b/.github/workflows/build-wheel.yml
@@ -0,0 +1,38 @@
+# Builds and uploads python build artifacts.
+
+name: build wheel
+
+on:
+ workflow_dispatch:
+ workflow_call:
+
+jobs:
+ build-installer:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5 # expected run time: <2 min
+ steps:
+ - name: checkout
+ uses: actions/checkout@v6
+
+ - name: setup python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: install pypa/build
+ run: pip install --upgrade build
+
+ - name: setup frontend
+ uses: ./.github/actions/install-frontend-deps
+
+ - name: build wheel
+ id: build_wheel
+ run: ./scripts/build_wheel.sh
+
+ - name: upload python distribution artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: dist
+ path: ${{ steps.build_wheel.outputs.DIST_PATH }}
diff --git a/.github/workflows/clean-caches.yml b/.github/workflows/clean-caches.yml
index e5acdeab1b9..73d742f3041 100644
--- a/.github/workflows/clean-caches.yml
+++ b/.github/workflows/clean-caches.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v3
+ uses: actions/checkout@v6
- name: Cleanup
run: |
diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml
index 9636911b2e9..40f75cebb88 100644
--- a/.github/workflows/close-inactive-issues.yml
+++ b/.github/workflows/close-inactive-issues.yml
@@ -14,7 +14,7 @@ jobs:
issues: write
pull-requests: write
steps:
- - uses: actions/stale@v8
+ - uses: actions/stale@v10
with:
days-before-issue-stale: ${{ env.DAYS_BEFORE_ISSUE_STALE }}
days-before-issue-close: ${{ env.DAYS_BEFORE_ISSUE_CLOSE }}
@@ -23,6 +23,7 @@ jobs:
close-issue-message: "Due to inactivity, this issue was automatically closed. If you are still experiencing the issue, please recreate the issue."
days-before-pr-stale: -1
days-before-pr-close: -1
+ only-labels: "bug"
exempt-issue-labels: "Active Issue"
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 00000000000..0e9c5774ba3
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,149 @@
+name: 'docs'
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ types:
+ - 'ready_for_review'
+ - 'opened'
+ - 'synchronize'
+ workflow_dispatch:
+ inputs:
+ deploy_target:
+ description: 'Deploy target (custom = invoke.ai, ghpages = invoke-ai.github.io/InvokeAI)'
+ type: choice
+ options:
+ - custom
+ - ghpages
+ default: custom
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ 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@v6
+ 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:
+ - name: checkout
+ uses: actions/checkout@v6
+
+ # Python (needed for generate-docs-data)
+ - name: setup uv
+ uses: astral-sh/setup-uv@v8.1.0
+ with:
+ version: '0.11.12'
+ enable-cache: true
+ python-version: '3.11'
+
+ - name: setup python
+ uses: actions/setup-python@v6
+ 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 sync --frozen
+
+ # Node (needed for docs build)
+ - name: setup node
+ uses: actions/setup-node@v6
+ with:
+ node-version: '22'
+
+ - name: setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 10
+ run_install: false
+
+ - name: install docs dependencies
+ run: pnpm install --prefer-frozen-lockfile
+ working-directory: docs
+
+ # Checks
+ - name: verify generated docs data
+ run: pnpm run check-docs-data
+ working-directory: docs
+
+ - name: build docs
+ run: pnpm build
+ working-directory: docs
+ env:
+ DEPLOY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_target || 'custom' }}
+ ENABLE_ANALYTICS: ${{ github.ref == 'refs/heads/main' && (github.event_name != 'workflow_dispatch' || inputs.deploy_target == 'custom') }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: verify deploy output
+ run: pnpm run check-deploy-output
+ working-directory: docs
+ env:
+ DEPLOY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_target || 'custom' }}
+
+ # Upload artifact for deploy (main branch only)
+ - name: upload pages artifact
+ if: github.ref == 'refs/heads/main'
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: docs/dist
+
+ deploy:
+ 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 }}
+ steps:
+ - name: deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml
index da19b74ebcc..b36fbeb650b 100644
--- a/.github/workflows/frontend-checks.yml
+++ b/.github/workflows/frontend-checks.yml
@@ -39,12 +39,28 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10 # expected run time: <2 min
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ - name: Fail if package-lock.json is added/modified (pnpm only)
+ shell: bash
+ working-directory: .
+ run: |
+ set -euo pipefail
+ git fetch --no-tags --prune --depth=1 origin "${{ github.base_ref }}"
+ if git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -E '(^|/)package-lock\.json$'; then
+ echo "::error::package-lock.json was added or modified. This repo uses pnpm only."
+ exit 1
+ fi
- name: check for changed frontend files
if: ${{ inputs.always_run != true }}
id: changed-files
- uses: tj-actions/changed-files@v42
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
with:
files_yaml: |
frontend:
diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml
index 086cff7b3d4..abb1fb8419f 100644
--- a/.github/workflows/frontend-tests.yml
+++ b/.github/workflows/frontend-tests.yml
@@ -39,12 +39,17 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10 # expected run time: <2 min
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: check for changed frontend files
if: ${{ inputs.always_run != true }}
id: changed-files
- uses: tj-actions/changed-files@v42
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
with:
files_yaml: |
frontend:
diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml
index 1a98512190a..b7689b12021 100644
--- a/.github/workflows/label-pr.yml
+++ b/.github/workflows/label-pr.yml
@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: label PRs
- uses: actions/labeler@v5
+ uses: actions/labeler@v6
with:
configuration-path: .github/pr_labels.yml
diff --git a/.github/workflows/lfs-checks.yml b/.github/workflows/lfs-checks.yml
new file mode 100644
index 00000000000..a3b845025a8
--- /dev/null
+++ b/.github/workflows/lfs-checks.yml
@@ -0,0 +1,30 @@
+# Checks that large files and LFS-tracked files are properly checked in with pointer format.
+# Uses https://github.com/ppremk/lfs-warning to detect LFS issues.
+
+name: 'lfs checks'
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ types:
+ - 'ready_for_review'
+ - 'opened'
+ - 'synchronize'
+ merge_group:
+ workflow_dispatch:
+
+jobs:
+ lfs-check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ # Required to label and comment on the PRs
+ pull-requests: write
+ steps:
+ - name: checkout
+ uses: actions/checkout@v6
+
+ - name: check lfs files
+ uses: ppremk/lfs-warning@v3.3
diff --git a/.github/workflows/mkdocs-material.yml b/.github/workflows/mkdocs-material.yml
deleted file mode 100644
index 419d87f37bb..00000000000
--- a/.github/workflows/mkdocs-material.yml
+++ /dev/null
@@ -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@v4
-
- - name: setup python
- uses: actions/setup-python@v5
- with:
- python-version: '3.10'
- 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
diff --git a/.github/workflows/openapi-checks.yml b/.github/workflows/openapi-checks.yml
new file mode 100644
index 00000000000..a3512c581c9
--- /dev/null
+++ b/.github/workflows/openapi-checks.yml
@@ -0,0 +1,115 @@
+# Runs OpenAPI schema quality checks.
+# Checked-in OpenAPI schema should match the generated server schema.
+#
+# Checks for changes to files before running the checks.
+# If always_run is true, always runs the checks.
+
+name: 'openapi checks'
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ types:
+ - 'ready_for_review'
+ - 'opened'
+ - 'synchronize'
+ merge_group:
+ workflow_dispatch:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+ workflow_call:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+
+jobs:
+ openapi-checks:
+ env:
+ # uv requires a venv by default - but for this, we can simply use the system python
+ UV_SYSTEM_PYTHON: 1
+ runs-on: ubuntu-22.04
+ timeout-minutes: 15 # expected run time: <5 min
+ steps:
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: Free up more disk space on the runner
+ # https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
+ run: |
+ echo "----- Free space before cleanup"
+ df -h
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+ if [ -f /mnt/swapfile ]; then
+ sudo swapoff /mnt/swapfile
+ sudo rm -rf /mnt/swapfile
+ fi
+ echo "----- Free space after cleanup"
+ df -h
+
+ - name: check for changed files
+ if: ${{ inputs.always_run != true }}
+ id: changed-files
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@a284dc1814e3fd07f2e34267fc8f81227ed29fb8
+ with:
+ files_yaml: |
+ src:
+ - 'pyproject.toml'
+ - 'invokeai/**'
+
+ - name: setup uv
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: astral-sh/setup-uv@v5
+ with:
+ version: '0.6.10'
+ enable-cache: true
+ python-version: '3.11'
+
+ - name: setup python
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: install dependencies
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ env:
+ UV_INDEX: ${{ matrix.extra-index-url }}
+ run: uv pip install --editable .
+
+ - name: install frontend dependencies
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: ./.github/actions/install-frontend-deps
+
+ - name: copy schema
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: cp invokeai/frontend/web/openapi.json invokeai/frontend/web/openapi_orig.json
+ shell: bash
+
+ - name: generate schema
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: cd invokeai/frontend/web && uv run ../../../scripts/generate_openapi_schema.py > openapi.json && pnpm prettier --write openapi.json
+ shell: bash
+
+ - name: compare files
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: |
+ if ! diff invokeai/frontend/web/openapi.json invokeai/frontend/web/openapi_orig.json; then
+ echo "Files are different!";
+ exit 1;
+ fi
+ shell: bash
diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml
index 94232e15768..b08bc611cec 100644
--- a/.github/workflows/python-checks.yml
+++ b/.github/workflows/python-checks.yml
@@ -34,16 +34,24 @@ on:
jobs:
python-checks:
+ env:
+ # uv requires a venv by default - but for this, we can simply use the system python
+ UV_SYSTEM_PYTHON: 1
runs-on: ubuntu-latest
timeout-minutes: 5 # expected run time: <1 min
steps:
- name: checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: check for changed python files
if: ${{ inputs.always_run != true }}
id: changed-files
- uses: tj-actions/changed-files@v42
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
with:
files_yaml: |
python:
@@ -52,25 +60,23 @@ jobs:
- '!invokeai/frontend/web/**'
- 'tests/**'
- - name: setup python
+ - name: setup uv
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- uses: actions/setup-python@v5
+ uses: astral-sh/setup-uv@v8.1.0
with:
- python-version: '3.10'
- cache: pip
- cache-dependency-path: pyproject.toml
+ version: '0.6.10'
+ enable-cache: true
- - name: install ruff
+ - name: check pypi classifiers
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- run: pip install ruff
- shell: bash
+ run: uv run --no-project scripts/check_classifiers.py ./pyproject.toml
- name: ruff check
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- run: ruff check --output-format=github .
+ run: uv tool run ruff@0.11.2 check --output-format=github .
shell: bash
- name: ruff format
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- run: ruff format --check .
+ run: uv tool run ruff@0.11.2 format --check .
shell: bash
diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index 8805989a69b..67351c2b387 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -39,28 +39,19 @@ jobs:
strategy:
matrix:
python-version:
- - '3.10'
- '3.11'
+ - '3.12'
platform:
- - linux-cuda-11_7
- - linux-rocm-5_2
- linux-cpu
- macos-default
- windows-cpu
include:
- - platform: linux-cuda-11_7
- os: ubuntu-22.04
- github-env: $GITHUB_ENV
- - platform: linux-rocm-5_2
- os: ubuntu-22.04
- extra-index-url: 'https://download.pytorch.org/whl/rocm5.2'
- github-env: $GITHUB_ENV
- platform: linux-cpu
- os: ubuntu-22.04
+ os: ubuntu-24.04
extra-index-url: 'https://download.pytorch.org/whl/cpu'
github-env: $GITHUB_ENV
- platform: macos-default
- os: macOS-12
+ os: macOS-14
github-env: $GITHUB_ENV
- platform: windows-cpu
os: windows-2022
@@ -70,14 +61,21 @@ jobs:
timeout-minutes: 15 # expected run time: 2-6 min, depending on platform
env:
PIP_USE_PEP517: '1'
+
steps:
- name: checkout
- uses: actions/checkout@v4
+ # https://github.com/nschloe/action-cached-lfs-checkout
+ uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc
- name: check for changed python files
if: ${{ inputs.always_run != true }}
id: changed-files
- uses: tj-actions/changed-files@v42
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
with:
files_yaml: |
python:
@@ -86,21 +84,20 @@ jobs:
- '!invokeai/frontend/web/**'
- 'tests/**'
- - name: setup python
+ - name: setup uv
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- uses: actions/setup-python@v5
+ uses: astral-sh/setup-uv@v8.1.0
with:
+ version: '0.6.10'
+ enable-cache: true
python-version: ${{ matrix.python-version }}
- cache: pip
- cache-dependency-path: pyproject.toml
- name: install dependencies
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
env:
- PIP_EXTRA_INDEX_URL: ${{ matrix.extra-index-url }}
- run: >
- pip3 install --editable=".[test]"
+ UV_INDEX: ${{ matrix.extra-index-url }}
+ run: uv sync --no-progress --locked --extra test
- name: run pytest
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
- run: pytest
+ run: uv run --no-sync pytest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0f09c0b245d..30e87b53dcb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: check python version
uses: samuelcolvin/check-python-version@v4
@@ -49,7 +49,7 @@ jobs:
always_run: true
build:
- uses: ./.github/workflows/build-installer.yml
+ uses: ./.github/workflows/build-wheel.yml
publish-testpypi:
runs-on: ubuntu-latest
@@ -70,7 +70,7 @@ jobs:
id-token: write
steps:
- name: download distribution from build job
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: dist
path: dist/
@@ -99,7 +99,7 @@ jobs:
id-token: write
steps:
- name: download distribution from build job
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: dist
path: dist/
diff --git a/.github/workflows/typegen-checks.yml b/.github/workflows/typegen-checks.yml
new file mode 100644
index 00000000000..1f1f0b1042e
--- /dev/null
+++ b/.github/workflows/typegen-checks.yml
@@ -0,0 +1,115 @@
+# Runs typegen schema quality checks.
+# Frontend types should match the server.
+#
+# Checks for changes to files before running the checks.
+# If always_run is true, always runs the checks.
+
+name: 'typegen checks'
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ types:
+ - 'ready_for_review'
+ - 'opened'
+ - 'synchronize'
+ merge_group:
+ workflow_dispatch:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+ workflow_call:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+
+jobs:
+ typegen-checks:
+ env:
+ # uv requires a venv by default - but for this, we can simply use the system python
+ UV_SYSTEM_PYTHON: 1
+ runs-on: ubuntu-22.04
+ timeout-minutes: 15 # expected run time: <5 min
+ steps:
+ - name: checkout
+ uses: actions/checkout@v6
+
+ - name: Free up more disk space on the runner
+ # https://github.com/actions/runner-images/issues/2840#issuecomment-1284059930
+ run: |
+ echo "----- Free space before cleanup"
+ df -h
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+ if [ -f /mnt/swapfile ]; then
+ sudo swapoff /mnt/swapfile
+ sudo rm -rf /mnt/swapfile
+ fi
+ echo "----- Free space after cleanup"
+ df -h
+
+ - name: check for changed files
+ if: ${{ inputs.always_run != true }}
+ id: changed-files
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
+ with:
+ files_yaml: |
+ src:
+ - 'pyproject.toml'
+ - 'invokeai/**'
+
+ - name: setup uv
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: astral-sh/setup-uv@v8.1.0
+ with:
+ version: '0.6.10'
+ enable-cache: true
+ python-version: '3.11'
+
+ - name: setup python
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: install dependencies
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ env:
+ UV_INDEX: ${{ matrix.extra-index-url }}
+ run: uv pip install --editable .
+
+ - name: install frontend dependencies
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ uses: ./.github/actions/install-frontend-deps
+
+ - name: copy schema
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: cp invokeai/frontend/web/src/services/api/schema.ts invokeai/frontend/web/src/services/api/schema_orig.ts
+ shell: bash
+
+ - name: generate schema
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: cd invokeai/frontend/web && uv run ../../../scripts/generate_openapi_schema.py | pnpm typegen
+ shell: bash
+
+ - name: compare files
+ if: ${{ steps.changed-files.outputs.src_any_changed == 'true' || inputs.always_run == true }}
+ run: |
+ if ! diff invokeai/frontend/web/src/services/api/schema.ts invokeai/frontend/web/src/services/api/schema_orig.ts; then
+ echo "Files are different!";
+ exit 1;
+ fi
+ shell: bash
diff --git a/.github/workflows/uv-lock-checks.yml b/.github/workflows/uv-lock-checks.yml
new file mode 100644
index 00000000000..d57163165fb
--- /dev/null
+++ b/.github/workflows/uv-lock-checks.yml
@@ -0,0 +1,68 @@
+# Check the `uv` lockfile for consistency with `pyproject.toml`.
+#
+# If this check fails, you should run `uv lock` to update the lockfile.
+
+name: 'uv lock checks'
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+ types:
+ - 'ready_for_review'
+ - 'opened'
+ - 'synchronize'
+ merge_group:
+ workflow_dispatch:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+ workflow_call:
+ inputs:
+ always_run:
+ description: 'Always run the checks'
+ required: true
+ type: boolean
+ default: true
+
+jobs:
+ uv-lock-checks:
+ env:
+ # uv requires a venv by default - but for this, we can simply use the system python
+ UV_SYSTEM_PYTHON: 1
+ runs-on: ubuntu-latest
+ timeout-minutes: 5 # expected run time: <1 min
+ steps:
+ - name: checkout
+ uses: actions/checkout@v6
+
+ - name: check for changed python files
+ if: ${{ inputs.always_run != true }}
+ id: changed-files
+ # Pinned to the _hash_ for v45.0.9 to prevent supply-chain attacks.
+ # See:
+ # - CVE-2025-30066
+ # - https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised
+ # - https://github.com/tj-actions/changed-files/issues/2463
+ uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96
+ with:
+ files_yaml: |
+ uvlock-pyprojecttoml:
+ - 'pyproject.toml'
+ - 'uv.lock'
+
+ - name: setup uv
+ if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
+ uses: astral-sh/setup-uv@v8.1.0
+ with:
+ version: '0.6.10'
+ enable-cache: true
+
+ - name: check lockfile
+ if: ${{ steps.changed-files.outputs.uvlock-pyprojecttoml_any_changed == 'true' || inputs.always_run == true }}
+ run: uv lock --locked # this will exit with 1 if the lockfile is not consistent with pyproject.toml
+ shell: bash
diff --git a/.gitignore b/.gitignore
index 29d27d78ed5..cc037f09abd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,9 +79,6 @@ instance/
# Scrapy stuff:
.scrapy
-# Sphinx documentation
-docs/_build/
-
# PyBuilder
.pybuilder/
target/
@@ -145,9 +142,6 @@ ENV/
# Rope project settings
.ropeproject
-# mkdocs documentation
-/site
-
# mypy
.mypy_cache/
.dmypy.json
@@ -179,7 +173,9 @@ cython_debug/
# Scratch folder
.scratch/
+worktrees/
.vscode/
+.zed/
# source installer files
installer/*zip
@@ -188,3 +184,9 @@ installer/install.sh
installer/update.bat
installer/update.sh
installer/InvokeAI-Installer/
+.aider*
+
+.claude/
+
+# Weblate configuration file
+weblate.ini
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000000..517f38666b4
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v22.14.0
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6cff07a959b..f128557bc08 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,21 +4,29 @@ repos:
hooks:
- id: black
name: black
- stages: [commit]
+ stages: [pre-commit]
language: system
entry: black
types: [python]
- id: flake8
name: flake8
- stages: [commit]
+ stages: [pre-commit]
language: system
entry: flake8
types: [python]
- id: isort
name: isort
- stages: [commit]
+ stages: [pre-commit]
language: system
entry: isort
- types: [python]
\ No newline at end of file
+ types: [python]
+
+ - id: uvlock
+ name: uv lock
+ stages: [pre-commit]
+ language: system
+ entry: uv lock
+ files: ^pyproject\.toml$
+ pass_filenames: false
\ No newline at end of file
diff --git a/Makefile b/Makefile
index e858a89e2b3..9830f7167fc 100644
--- a/Makefile
+++ b/Makefile
@@ -11,24 +11,29 @@ help:
@echo "mypy Run mypy using the config in pyproject.toml to identify type mismatches and other coding errors"
@echo "mypy-all Run mypy ignoring the config in pyproject.tom but still ignoring missing imports"
@echo "test Run the unit tests."
- @echo "update-config-docstring Update the app's config docstring so mkdocs can autogenerate it correctly."
- @echo "frontend-install Install the pnpm modules needed for the front end"
- @echo "frontend-build Build the frontend in order to run on localhost:9090"
+ @echo "frontend-install Install the pnpm modules needed for the frontend"
+ @echo "frontend-build Build the frontend for localhost:9090"
+ @echo "frontend-test Run the frontend test suite once"
@echo "frontend-dev Run the frontend in developer mode on localhost:5173"
+ @echo "frontend-openapi Generate the OpenAPI schema"
@echo "frontend-typegen Generate types for the frontend from the OpenAPI schema"
- @echo "installer-zip Build the installer .zip file for the current version"
+ @echo "frontend-lint Run frontend checks and fixable lint/format steps"
+ @echo "wheel Build the wheel for the current version"
@echo "tag-release Tag the GitHub repository with the current version (use at release time only!)"
@echo "openapi Generate the OpenAPI schema for the app, outputting to stdout"
+ @echo "docs-install Install the pnpm modules needed for the docs site"
+ @echo "docs-dev Serve the astro starlight docs site with live reload"
+ @echo "docs-build Build the docs site for production"
+ @echo "docs-preview Preview the docs site locally"
# Runs ruff, fixing any safely-fixable errors and formatting
ruff:
- ruff check . --fix
- ruff format .
+ cd invokeai && uv tool run ruff@0.11.2 format
# Runs ruff, fixing all errors it can fix and formatting
ruff-unsafe:
ruff check . --fix --unsafe-fixes
- ruff format .
+ ruff format
# Runs mypy, using the config in pyproject.toml
mypy:
@@ -43,10 +48,6 @@ mypy-all:
test:
pytest ./tests
-# Update config docstring
-update-config-docstring:
- python scripts/update_config_docstring.py
-
# Install the pnpm modules needed for the front end
frontend-install:
rm -rf invokeai/frontend/web/node_modules
@@ -56,21 +57,52 @@ frontend-install:
frontend-build:
cd invokeai/frontend/web && pnpm build
+# Run the frontend test suite once
+frontend-test:
+ cd invokeai/frontend/web && pnpm run test:run
+
# Run the frontend in dev mode
frontend-dev:
cd invokeai/frontend/web && pnpm dev
+# Generate the OpenAPI Schema for the app
+frontend-openapi:
+ cd invokeai/frontend/web && \
+ python ../../../scripts/generate_openapi_schema.py > openapi.json && \
+ pnpm prettier --write openapi.json
+
frontend-typegen:
cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen
-# Installer zip file
-installer-zip:
- cd installer && ./create_installer.sh
+frontend-lint:
+ cd invokeai/frontend/web/src && \
+ pnpm lint:tsc && \
+ pnpm lint:dpdm && \
+ pnpm lint:eslint --fix && \
+ pnpm lint:prettier --write
+
+# Tag the release
+wheel:
+ cd scripts && ./build_wheel.sh
# Tag the release
tag-release:
- cd installer && ./tag_release.sh
+ cd scripts && ./tag_release.sh
# Generate the OpenAPI Schema for the app
openapi:
python scripts/generate_openapi_schema.py
+
+# Install the pnpm modules needed for the docs site
+docs-install:
+ cd docs && pnpm install
+
+# Serve the astro starlight docs site w/ live reload
+docs-dev:
+ cd docs && pnpm run dev
+
+docs-build:
+ cd docs && DEPLOY_TARGET='custom' pnpm run build
+
+docs-preview:
+ cd docs && pnpm run preview
diff --git a/README.md b/README.md
index 41de4882ee3..afc0211bd91 100644
--- a/README.md
+++ b/README.md
@@ -4,38 +4,33 @@
# Invoke - Professional Creative AI Tools for Visual Media
-#### To learn more about Invoke, or implement our Business solutions, visit [invoke.com]
-
[![discord badge]][discord link] [![latest release badge]][latest release link] [![github stars badge]][github stars link] [![github forks badge]][github forks link] [![CI checks on main badge]][CI checks on main link] [![latest commit to main badge]][latest commit to main link] [![github open issues badge]][github open issues link] [![github open prs badge]][github open prs link] [![translation status badge]][translation status link]
Invoke is a leading creative engine built to empower professionals and enthusiasts alike. Generate and create stunning visual media using the latest AI-driven technologies. Invoke offers an industry leading web-based UI, and serves as the foundation for multiple commercial products.
-[Installation and Updates][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs]
-
-
+- Free to use under a commercially-friendly license
+- Download and install on compatible hardware
+- Generate, refine, iterate on images, and build workflows

-
+---
+> ## 📣 Are you a new or returning InvokeAI user?
+> Take our first annual [User's Survey](https://forms.gle/rCE5KuQ7Wfrd1UnS7)
-## Quick Start
+---
-1. Download and unzip the installer from the bottom of the [latest release][latest release link].
-2. Run the installer script.
+# Documentation
- - **Windows**: Double-click on the `install.bat` script.
- - **macOS**: Open a Terminal window, drag the file `install.sh` from Finder into the Terminal, and press enter.
- - **Linux**: Run `install.sh`.
+| **Quick Links** |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [Installation and Updates][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs] |
-3. When prompted, enter a location for the install and select your GPU type.
-4. Once the install finishes, find the directory you selected during install. The default location is `C:\Users\Username\invokeai` for Windows or `~/invokeai` for Linux/macOS.
-5. Run the launcher script (`invoke.bat` for Windows, `invoke.sh` for macOS and Linux) the same way you ran the installer script in step 2.
-6. Select option 1 to start the application. Once it starts up, open your browser and go to .
-7. Open the model manager tab to install a starter model and then you'll be ready to generate.
+# Installation
-More detail, including hardware requirements and manual install instructions, are available in the [installation documentation][installation docs].
+To get started with Invoke, [Download the Launcher](https://github.com/invoke-ai/launcher/releases/latest).
## Troubleshooting, FAQ and Support
@@ -57,21 +52,45 @@ The Unified Canvas is a fully integrated canvas implementation with support for
### Workflows & Nodes
-Invoke offers a fully featured workflow management solution, enabling users to combine the power of node-based workflows with the easy of a UI. This allows for customizable generation pipelines to be developed and shared by users looking to create specific workflows to support their production use-cases.
+Invoke offers a fully featured workflow management solution, enabling users to combine the power of node-based workflows with the ease of a UI. This allows for customizable generation pipelines to be developed and shared by users looking to create specific workflows to support their production use-cases.
### Board & Gallery Management
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 2.0
+- SDXL
+- SD 3.5 Medium
+- SD 3.5 Large
+- CogView 4
+- Flux.1 Dev
+- Flux.1 Schnell
+- Flux.1 Kontext
+- Flux.1 Krea
+- Flux Redux
+- Flux Fill
+- Flux.2 Klein 4B
+- Flux.2 Klein 9B
+- Z-Image Turbo
+- Z-Image Base
+- Anima
+- Qwen Image
+- Qwen Image Edit
+- Nano Banana (API Only)
+- GPT Image (API Only)
+- Wan (API Only)
+
### Other features
-- Support for both ckpt and diffusers models
-- SD1.5, SD2.0, and SDXL support
+- Support for ckpt, diffusers, and some gguf models
- Upscaling Tools
- Embedding Manager & Support
- Model Manager & Support
- Workflow creation & management
- Node-Based Architecture
+- Object Segmentation & Selection Models (SAM / SAM2)
## Contributing
@@ -87,15 +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/
-[faq]: https://invoke-ai.github.io/InvokeAI/help/FAQ/
-[contributors]: https://invoke-ai.github.io/InvokeAI/other/CONTRIBUTORS/
-[invoke.com]: https://www.invoke.com/about
+[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/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/
+[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
@@ -114,3 +132,5 @@ Original portions of the software are Copyright © 2024 by respective contributo
[latest release link]: https://github.com/invoke-ai/InvokeAI/releases/latest
[translation status badge]: https://hosted.weblate.org/widgets/invokeai/-/svg-badge.svg
[translation status link]: https://hosted.weblate.org/engage/invokeai/
+[nvidia docker docs]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
+[amd docker docs]: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000000..5b3275535a5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,14 @@
+# Security Policy
+
+## Supported Versions
+
+Only the latest version of Invoke will receive security updates.
+We do not currently maintain multiple versions of the application with updates.
+
+## Reporting a Vulnerability
+
+To report a vulnerability, contact the Invoke team directly at security@invoke.ai
+
+At this time, we do not maintain a formal bug bounty program.
+
+You can also share identified security issues with our team on huntr.com
diff --git a/USER_ISOLATION_IMPLEMENTATION.md b/USER_ISOLATION_IMPLEMENTATION.md
new file mode 100644
index 00000000000..324c40db562
--- /dev/null
+++ b/USER_ISOLATION_IMPLEMENTATION.md
@@ -0,0 +1,169 @@
+# User Isolation Implementation Summary
+
+This document describes the implementation of user isolation features in the InvokeAI session queue and processing system to address issues identified in the enhancement request.
+
+## Issues Addressed
+
+### 1. Cross-User Image/Preview Visibility
+**Problem:** When two users are logged in simultaneously and one initiates a generation, the generation preview shows up in both users' browsers and the generated image gets saved to both users' image boards.
+
+**Solution:** Implemented socket-level event filtering based on user authentication:
+
+#### Backend Changes (`invokeai/app/api/sockets.py`):
+- Added socket authentication middleware in `_handle_connect()` method
+- Extracts JWT token from socket auth data or HTTP headers
+- Verifies token using existing `verify_token()` function
+- Stores `user_id` and `is_admin` in socket session for later use
+- Modified `_handle_queue_event()` to filter events by user:
+ - For `QueueItemEventBase` events, only emit to:
+ - The user who owns the queue item (`user_id` matches)
+ - Admin users (`is_admin` is True)
+ - For general queue events, emit to all subscribers
+
+#### Event System Changes (`invokeai/app/services/events/events_common.py`):
+- Added `user_id` field to `QueueItemEventBase` class
+- Updated all event builders to include `user_id` from queue items:
+ - `InvocationStartedEvent.build()`
+ - `InvocationProgressEvent.build()`
+ - `InvocationCompleteEvent.build()`
+ - `InvocationErrorEvent.build()`
+ - `QueueItemStatusChangedEvent.build()`
+
+### 2. Batch Field Values Privacy
+**Problem:** Users can see batch field values from generation processes launched by other users.
+
+**Solution:** Implemented field value sanitization at the API level:
+
+#### API Router Changes (`invokeai/app/api/routers/session_queue.py`):
+- Created `sanitize_queue_item_for_user()` helper function
+ - Clears `field_values` for non-admin users viewing other users' items
+ - Admins and item owners can see all field values
+- Updated endpoints to require authentication and sanitize responses:
+ - `list_all_queue_items()` - Added `CurrentUser` dependency
+ - `get_queue_items_by_item_ids()` - Added `CurrentUser` dependency
+ - `get_queue_item()` - Added `CurrentUser` dependency
+
+### 3. Queue Updates Across Browser Windows
+**Problem:** When the job queue tab is open in multiple browsers and a generation is begun in one browser window, the queue does not update in the other window.
+
+**Status:** This issue is likely resolved by the socket authentication and event filtering changes. The existing socket subscription mechanism (`subscribe_queue` event) already supports multiple connections per user. Testing is required to confirm this works correctly with the new authentication flow.
+
+### 4. User Information Display
+**Problem:** Queue table lacks user identification, making it difficult to know who launched which job.
+
+**Solution:** Added user information to queue items and UI:
+
+#### Database Layer (`invokeai/app/services/session_queue/session_queue_sqlite.py`):
+- Updated SQL queries to JOIN with `users` table
+- Modified methods to fetch user information:
+ - `get_queue_item()` - Now selects `display_name` and `email` from users table
+ - `dequeue()` - Includes user info
+ - `get_next()` - Includes user info
+ - `get_current()` - Includes user info
+ - `list_all_queue_items()` - Includes user info
+
+#### Data Model Changes (`invokeai/app/services/session_queue/session_queue_common.py`):
+- Added optional fields to `SessionQueueItem`:
+ - `user_display_name: Optional[str]` - Display name from users table
+ - `user_email: Optional[str]` - Email from users table
+ - Note: `user_id` field already existed from Migration 25
+
+#### Frontend UI Changes:
+- **Constants** (`constants.ts`): Added `user: '8rem'` column width
+- **Header** (`QueueListHeader.tsx`): Added "User" column header
+- **Item Component** (`QueueItemComponent.tsx`):
+ - Added logic to display user information (display_name → email → user_id)
+ - Added user column to queue item row
+ - Added tooltip with full username on hover
+ - Added "Hidden for privacy" message when field_values are null for non-owned items
+- **Localization** (`en.json`): Added translations:
+ - `"user": "User"`
+ - `"fieldValuesHidden": "Hidden for privacy"`
+
+## Security Considerations
+
+### Token Verification
+- Tokens are verified using the existing `verify_token()` function from `invokeai.app.services.auth.token_service`
+- Invalid or missing tokens default to "system" user with non-admin privileges
+- Socket connections without valid tokens are still accepted for backward compatibility but have limited access
+
+### Data Privacy
+- Field values are only visible to:
+ - The user who created the queue item
+ - Admin users
+- Non-admin users viewing other users' queue items see "Hidden for privacy" instead of field values
+
+### Admin Privileges
+- Admin users can see all queue events and field values across all users
+- Admin status is determined from the JWT token's `is_admin` field
+
+## Migration Notes
+
+No database migration is required. The changes leverage:
+- Existing `user_id` column in `session_queue` table (added in Migration 25)
+- Existing `users` table (added in Migration 25)
+- SQL LEFT JOINs to fetch user information (gracefully handles missing user records)
+
+## Testing Requirements
+
+### Backend Testing
+1. **Socket Authentication:**
+ - Verify valid tokens are accepted and user context is stored
+ - Verify invalid tokens default to system user
+ - Verify expired tokens are rejected
+
+2. **Event Filtering:**
+ - User A should only receive events for their own queue items
+ - Admin users should receive all events
+ - Non-admin users should not receive events from other users
+
+3. **Field Value Sanitization:**
+ - Non-admin users should see null field_values for other users' items
+ - Admins should see all field values
+ - Users should see their own field values
+
+### Frontend Testing
+1. **UI Display:**
+ - User column should display in queue list
+ - Display name should be shown when available
+ - Email should be shown as fallback when display name is missing
+ - User ID should be shown when both display name and email are missing
+ - Tooltip should show full username on hover
+
+2. **Field Values Display:**
+ - "Hidden for privacy" message should appear when viewing other users' items
+ - Own items should show field values normally
+
+3. **Multi-Browser Testing:**
+ - Open queue tab in two browsers with different users
+ - Start generation in one browser
+ - Verify other browser doesn't see the preview/progress
+ - Verify admin user can see all generations
+
+### Integration Testing
+1. Multi-user scenarios with simultaneous generations
+2. Queue updates across multiple browser windows
+3. Admin vs. non-admin privilege differentiation
+4. Socket reconnection handling
+
+## Known Limitations
+
+1. **TypeScript Types:**
+ - The OpenAPI schema needs to be regenerated to include new fields
+ - Run: `cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen`
+
+2. **Backward Compatibility:**
+ - System user ("system") entries will not have display name or email
+ - Existing queue items from before Migration 25 will have user_id="system"
+
+3. **Socket.IO Session Storage:**
+ - Socket.IO's in-memory session storage may not persist across server restarts
+ - Consider implementing persistent session storage if needed for production
+
+## Future Enhancements
+
+1. Add user filtering to queue list (show only my items vs. all items)
+2. Add permission system for queue management operations (cancel, retry, delete)
+3. Implement queue item ownership transfer for administrative purposes
+4. Add audit logging for queue operations with user attribution
+5. Consider implementing user-specific queue limits or quotas
diff --git a/docker/.env.sample b/docker/.env.sample
index aeb69bfd27a..7b10af936e1 100644
--- a/docker/.env.sample
+++ b/docker/.env.sample
@@ -19,8 +19,13 @@
## INVOKEAI_PORT is the port on which the InvokeAI web interface will be available
# INVOKEAI_PORT=9090
-## GPU_DRIVER can be set to either `nvidia` or `rocm` to enable GPU support in the container accordingly.
-# GPU_DRIVER=nvidia #| rocm
+## GPU_DRIVER can be set to either `cuda` or `rocm` to enable GPU support in the container accordingly.
+# GPU_DRIVER=cuda #| rocm
+
+## If you are using ROCM, you will need to ensure that the render group within the container and the host system use the same group ID.
+## To obtain the group ID of the render group on the host system, run `getent group render` and grab the number.
+# RENDER_GROUP_ID=
## CONTAINER_UID can be set to the UID of the user on the host system that should own the files in the container.
+## It is usually not necessary to change this. Use `id -u` on the host system to find the UID.
# CONTAINER_UID=1000
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 864bc5eb609..b1b709d54df 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,61 +1,11 @@
# syntax=docker/dockerfile:1.4
-## Builder stage
+#### Web UI ------------------------------------
-FROM library/ubuntu:23.04 AS builder
-
-ARG DEBIAN_FRONTEND=noninteractive
-RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
-RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
- --mount=type=cache,target=/var/lib/apt,sharing=locked \
- apt update && apt-get install -y \
- git \
- python3-venv \
- python3-pip \
- build-essential
-
-ENV INVOKEAI_SRC=/opt/invokeai
-ENV VIRTUAL_ENV=/opt/venv/invokeai
-
-ENV PATH="$VIRTUAL_ENV/bin:$PATH"
-ARG GPU_DRIVER=cuda
-ARG TARGETPLATFORM="linux/amd64"
-# unused but available
-ARG BUILDPLATFORM
-
-WORKDIR ${INVOKEAI_SRC}
-
-COPY invokeai ./invokeai
-COPY pyproject.toml ./
-
-# Editable mode helps use the same image for development:
-# the local working copy can be bind-mounted into the image
-# at path defined by ${INVOKEAI_SRC}
-# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
-# x86_64/CUDA is default
-RUN --mount=type=cache,target=/root/.cache/pip \
- python3 -m venv ${VIRTUAL_ENV} &&\
- if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
- extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cpu"; \
- elif [ "$GPU_DRIVER" = "rocm" ]; then \
- extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/rocm5.6"; \
- else \
- extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu121"; \
- fi &&\
-
- # xformers + triton fails to install on arm64
- if [ "$GPU_DRIVER" = "cuda" ] && [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
- pip install $extra_index_url_arg -e ".[xformers]"; \
- else \
- pip install $extra_index_url_arg -e "."; \
- fi
-
-# #### Build the Web UI ------------------------------------
-
-FROM node:20-slim AS web-builder
+FROM docker.io/node:22-slim AS web-builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
-RUN corepack enable
+RUN corepack use pnpm@10.x && corepack enable
WORKDIR /build
COPY invokeai/frontend/web/ ./
@@ -63,61 +13,95 @@ RUN --mount=type=cache,target=/pnpm/store \
pnpm install --frozen-lockfile
RUN npx vite build
-#### Runtime stage ---------------------------------------
+## Backend ---------------------------------------
-FROM library/ubuntu:23.04 AS runtime
+FROM library/ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
-ENV PYTHONUNBUFFERED=1
-ENV PYTHONDONTWRITEBYTECODE=1
-
-RUN apt update && apt install -y --no-install-recommends \
- git \
- curl \
- vim \
- tmux \
- ncdu \
- iotop \
- bzip2 \
- gosu \
- magic-wormhole \
- libglib2.0-0 \
- libgl1-mesa-glx \
- python3-venv \
- python3-pip \
- build-essential \
- libopencv-dev \
- libstdc++-10-dev &&\
- apt-get clean && apt-get autoclean
-
-
-ENV INVOKEAI_SRC=/opt/invokeai
-ENV VIRTUAL_ENV=/opt/venv/invokeai
-ENV INVOKEAI_ROOT=/invokeai
-ENV INVOKEAI_HOST=0.0.0.0
-ENV INVOKEAI_PORT=9090
-ENV PATH="$VIRTUAL_ENV/bin:$INVOKEAI_SRC:$PATH"
-ENV CONTAINER_UID=${CONTAINER_UID:-1000}
-ENV CONTAINER_GID=${CONTAINER_GID:-1000}
-
-# --link requires buldkit w/ dockerfile syntax 1.4
-COPY --link --from=builder ${INVOKEAI_SRC} ${INVOKEAI_SRC}
-COPY --link --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
-COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
+RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
+RUN --mount=type=cache,target=/var/cache/apt \
+ --mount=type=cache,target=/var/lib/apt \
+ apt update && apt install -y --no-install-recommends \
+ ca-certificates \
+ git \
+ gosu \
+ libglib2.0-0 \
+ libgl1 \
+ libglx-mesa0 \
+ build-essential \
+ libopencv-dev \
+ libstdc++-10-dev
+
+ENV \
+ PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ VIRTUAL_ENV=/opt/venv \
+ INVOKEAI_SRC=/opt/invokeai \
+ PYTHON_VERSION=3.12 \
+ UV_PYTHON=3.12 \
+ UV_COMPILE_BYTECODE=1 \
+ UV_MANAGED_PYTHON=1 \
+ UV_LINK_MODE=copy \
+ UV_PROJECT_ENVIRONMENT=/opt/venv \
+ INVOKEAI_ROOT=/invokeai \
+ INVOKEAI_HOST=0.0.0.0 \
+ INVOKEAI_PORT=9090 \
+ PATH="/opt/venv/bin:$PATH" \
+ CONTAINER_UID=${CONTAINER_UID:-1000} \
+ CONTAINER_GID=${CONTAINER_GID:-1000}
+
+ARG GPU_DRIVER=cuda
+
+# Install `uv` for package management
+COPY --from=ghcr.io/astral-sh/uv:0.6.9 /uv /uvx /bin/
+
+# Install python & allow non-root user to use it by traversing the /root dir without read permissions
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv python install ${PYTHON_VERSION} && \
+ # chmod --recursive a+rX /root/.local/share/uv/python
+ chmod 711 /root
+
+WORKDIR ${INVOKEAI_SRC}
+
+# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
+# bind-mount instead of copy to defer adding sources to the image until next layer.
+#
+# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
+# x86_64/CUDA is the default
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ # this is just to get the package manager to recognize that the project exists, without making changes to the docker layer
+ --mount=type=bind,source=invokeai/version,target=invokeai/version \
+ ulimit -n 30000 && \
+ uv sync --extra $GPU_DRIVER --frozen
# Link amdgpu.ids for ROCm builds
# contributed by https://github.com/Rubonnek
RUN mkdir -p "/opt/amdgpu/share/libdrm" &&\
- ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids"
-
-WORKDIR ${INVOKEAI_SRC}
+ ln -s "/usr/share/libdrm/amdgpu.ids" "/opt/amdgpu/share/libdrm/amdgpu.ids" && groupadd render
# build patchmatch
RUN cd /usr/lib/$(uname -p)-linux-gnu/pkgconfig/ && ln -sf opencv4.pc opencv.pc
-RUN python3 -c "from patchmatch import patch_match"
+RUN python -c "from patchmatch import patch_match"
RUN mkdir -p ${INVOKEAI_ROOT} && chown -R ${CONTAINER_UID}:${CONTAINER_GID} ${INVOKEAI_ROOT}
COPY docker/docker-entrypoint.sh ./
ENTRYPOINT ["/opt/invokeai/docker-entrypoint.sh"]
CMD ["invokeai-web"]
+
+# --link requires buldkit w/ dockerfile syntax 1.4, does not work with podman
+COPY --link --from=web-builder /build/dist ${INVOKEAI_SRC}/invokeai/frontend/web/dist
+
+# add sources last to minimize image changes on code changes
+COPY invokeai ${INVOKEAI_SRC}/invokeai
+
+# this should not increase image size because we've already installed dependencies
+# in a previous layer
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ ulimit -n 30000 && \
+ uv pip install -e .[$GPU_DRIVER]
+
diff --git a/docker/README.md b/docker/README.md
index 9e7ac15145d..b9c7c010f4a 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,41 +1,88 @@
-# InvokeAI Containerized
+# Invoke in Docker
-All commands should be run within the `docker` directory: `cd docker`
+First things first:
-## Quickstart :rocket:
+- Ensure that Docker can use your [NVIDIA][nvidia docker docs] or [AMD][amd docker docs] GPU.
+- This document assumes a Linux system, but should work similarly under Windows with WSL2.
+- We don't recommend running Invoke in Docker on macOS at this time. It works, but very slowly.
-On a known working Linux+Docker+CUDA (Nvidia) system, execute `./run.sh` in this directory. It will take a few minutes - depending on your internet speed - to install the core models. Once the application starts up, open `http://localhost:9090` in your browser to Invoke!
+## Quickstart
-For more configuration options (using an AMD GPU, custom root directory location, etc): read on.
+No `docker compose`, no persistence, single command, using the official images:
-## Detailed setup
+**CUDA (NVIDIA GPU):**
+
+```bash
+docker run --runtime=nvidia --gpus=all --publish 9090:9090 ghcr.io/invoke-ai/invokeai
+```
+
+**ROCm (AMD GPU):**
+
+```bash
+docker run --device /dev/kfd --device /dev/dri --publish 9090:9090 ghcr.io/invoke-ai/invokeai:main-rocm
+```
+
+Open `http://localhost:9090` in your browser once the container finishes booting, install some models, and generate away!
+
+### Data persistence
+
+To persist your generated images and downloaded models outside of the container, add a `--volume/-v` flag to the above command, e.g.:
+
+```bash
+docker run --volume /some/local/path:/invokeai {...etc...}
+```
+
+`/some/local/path/invokeai` will contain all your data.
+It can *usually* be reused between different installs of Invoke. Tread with caution and read the release notes!
+
+## Customize the container
+
+The included `run.sh` script is a convenience wrapper around `docker compose`. It can be helpful for passing additional build arguments to `docker compose`. Alternatively, the familiar `docker compose` commands work just as well.
+
+```bash
+cd docker
+cp .env.sample .env
+# edit .env to your liking if you need to; it is well commented.
+./run.sh
+```
+
+It will take a few minutes to build the image the first time. Once the application starts up, open `http://localhost:9090` in your browser to invoke!
+
+>[!TIP]
+>When using the `run.sh` script, the container will continue running after Ctrl+C. To shut it down, use the `docker compose down` command.
+
+## Docker setup in detail
#### Linux
-1. Ensure builkit is enabled in the Docker daemon settings (`/etc/docker/daemon.json`)
+1. Ensure buildkit is enabled in the Docker daemon settings (`/etc/docker/daemon.json`)
2. Install the `docker compose` plugin using your package manager, or follow a [tutorial](https://docs.docker.com/compose/install/linux/#install-using-the-repository).
- - The deprecated `docker-compose` (hyphenated) CLI continues to work for now.
+ - The deprecated `docker-compose` (hyphenated) CLI probably won't work. Update to a recent version.
3. Ensure docker daemon is able to access the GPU.
- - You may need to install [nvidia-container-toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
+ - [NVIDIA docs](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
+ - [AMD docs](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html)
#### macOS
+> [!TIP]
+> You'll be better off installing Invoke directly on your system, because Docker can not use the GPU on macOS.
+
+If you are still reading:
+
1. Ensure Docker has at least 16GB RAM
2. Enable VirtioFS for file sharing
3. Enable `docker compose` V2 support
-This is done via Docker Desktop preferences
+This is done via Docker Desktop preferences.
-### Configure Invoke environment
+### Configure the Invoke Environment
-1. Make a copy of `.env.sample` and name it `.env` (`cp .env.sample .env` (Mac/Linux) or `copy example.env .env` (Windows)). Make changes as necessary. Set `INVOKEAI_ROOT` to an absolute path to:
- a. the desired location of the InvokeAI runtime directory, or
- b. an existing, v3.0.0 compatible runtime directory.
+1. Make a copy of `.env.sample` and name it `.env` (`cp .env.sample .env` (Mac/Linux) or `copy example.env .env` (Windows)). Make changes as necessary. Set `INVOKEAI_ROOT` to an absolute path to the desired location of the InvokeAI runtime directory. It may be an existing directory from a previous installation (post 4.0.0).
1. Execute `run.sh`
The image will be built automatically if needed.
-The runtime directory (holding models and outputs) will be created in the location specified by `INVOKEAI_ROOT`. The default location is `~/invokeai`. The runtime directory will be populated with the base configs and models necessary to start generating.
+The runtime directory (holding models and outputs) will be created in the location specified by `INVOKEAI_ROOT`. The default location is `~/invokeai`. Navigate to the Model Manager tab and install some models before generating.
### Use a GPU
@@ -43,9 +90,9 @@ The runtime directory (holding models and outputs) will be created in the locati
- WSL2 is *required* for Windows.
- only `x86_64` architecture is supported.
-The Docker daemon on the system must be already set up to use the GPU. In case of Linux, this involves installing `nvidia-docker-runtime` and configuring the `nvidia` runtime as default. Steps will be different for AMD. Please see Docker documentation for the most up-to-date instructions for using your GPU with Docker.
+The Docker daemon on the system must be already set up to use the GPU. In case of Linux, this involves installing `nvidia-docker-runtime` and configuring the `nvidia` runtime as default. Steps will be different for AMD. Please see Docker/NVIDIA/AMD documentation for the most up-to-date instructions for using your GPU with Docker.
-To use an AMD GPU, set `GPU_DRIVER=rocm` in your `.env` file.
+To use an AMD GPU, set `GPU_DRIVER=rocm` in your `.env` file before running `./run.sh`.
## Customize
@@ -59,30 +106,12 @@ Values are optional, but setting `INVOKEAI_ROOT` is highly recommended. The defa
INVOKEAI_ROOT=/Volumes/WorkDrive/invokeai
HUGGINGFACE_TOKEN=the_actual_token
CONTAINER_UID=1000
-GPU_DRIVER=nvidia
+GPU_DRIVER=cuda
```
-Any environment variables supported by InvokeAI can be set here - please 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.
-## Even More Customizing!
+---
-See the `docker-compose.yml` file. The `command` instruction can be uncommented and used to run arbitrary startup commands. Some examples below.
-
-### Reconfigure the runtime directory
-
-Can be used to download additional models from the supported model list
-
-In conjunction with `INVOKEAI_ROOT` can be also used to initialize a runtime directory
-
-```yaml
-command:
- - invokeai-configure
- - --yes
-```
-
-Or install models:
-
-```yaml
-command:
- - invokeai-model-install
-```
+[nvidia docker docs]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
+[amd docker docs]: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 2ad50e74a17..2e5bc91f260 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,9 +1,7 @@
# Copyright (c) 2023 Eugene Brodsky https://github.com/ebr
-version: '3.8'
-
x-invokeai: &invokeai
- image: "local/invokeai:latest"
+ image: "ghcr.io/invoke-ai/invokeai:latest"
build:
context: ..
dockerfile: docker/Dockerfile
@@ -32,7 +30,7 @@ x-invokeai: &invokeai
services:
- invokeai-nvidia:
+ invokeai-cuda:
<<: *invokeai
deploy:
resources:
@@ -49,8 +47,9 @@ services:
invokeai-rocm:
<<: *invokeai
- devices:
- - /dev/kfd:/dev/kfd
- - /dev/dri:/dev/dri
+ environment:
+ - AMD_VISIBLE_DEVICES=all
+ - RENDER_GROUP_ID=${RENDER_GROUP_ID}
+ runtime: amd
profiles:
- rocm
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index 7fb52f3af9e..12f24a93527 100755
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -16,26 +16,42 @@ set -e -o pipefail
USER_ID=${CONTAINER_UID:-1000}
USER=ubuntu
+# if the user does not exist, create it. It is expected to be present on ubuntu >=24.x
+_=$(id ${USER} 2>&1) || useradd -u ${USER_ID} ${USER}
+# ensure the UID is correct
usermod -u ${USER_ID} ${USER} 1>/dev/null
+## ROCM specific configuration
+# render group within the container must match the host render group
+# otherwise the container will not be able to access the host GPU.
+if [[ -v "RENDER_GROUP_ID" ]] && [[ ! -z "${RENDER_GROUP_ID}" ]]; then
+ # ensure the render group exists
+ groupmod -g ${RENDER_GROUP_ID} render
+ usermod -a -G render ${USER}
+ usermod -a -G video ${USER}
+fi
+
+
### Set the $PUBLIC_KEY env var to enable SSH access.
# We do not install openssh-server in the image by default to avoid bloat.
# but it is useful to have the full SSH server e.g. on Runpod.
# (use SCP to copy files to/from the image, etc)
if [[ -v "PUBLIC_KEY" ]] && [[ ! -d "${HOME}/.ssh" ]]; then
- apt-get update
- apt-get install -y openssh-server
- pushd "$HOME"
- mkdir -p .ssh
- echo "${PUBLIC_KEY}" > .ssh/authorized_keys
- chmod -R 700 .ssh
- popd
- service ssh start
+ apt-get update
+ apt-get install -y openssh-server
+ pushd "$HOME"
+ mkdir -p .ssh
+ echo "${PUBLIC_KEY}" >.ssh/authorized_keys
+ chmod -R 700 .ssh
+ popd
+ service ssh start
fi
mkdir -p "${INVOKEAI_ROOT}"
-chown --recursive ${USER} "${INVOKEAI_ROOT}"
+chown --recursive ${USER} "${INVOKEAI_ROOT}" || true
cd "${INVOKEAI_ROOT}"
+export HF_HOME=${HF_HOME:-$INVOKEAI_ROOT/.cache/huggingface}
+export MPLCONFIGDIR=${MPLCONFIGDIR:-$INVOKEAI_ROOT/.matplotlib}
# Run the CMD as the Container User (not root).
exec gosu ${USER} "$@"
diff --git a/docker/run.sh b/docker/run.sh
index d413e53453b..272d4e44a5c 100755
--- a/docker/run.sh
+++ b/docker/run.sh
@@ -8,11 +8,15 @@ run() {
local build_args=""
local profile=""
+ # create .env file if it doesn't exist, otherwise docker compose will fail
touch .env
+
+ # parse .env file for build args
build_args=$(awk '$1 ~ /=[^$]/ && $0 !~ /^#/ {print "--build-arg " $0 " "}' .env) &&
- profile="$(awk -F '=' '/GPU_DRIVER/ {print $2}' .env)"
+ profile="$(awk -F '=' '/GPU_DRIVER=/ {print $2}' .env)"
- [[ -z "$profile" ]] && profile="nvidia"
+ # default to 'cuda' profile
+ [[ -z "$profile" ]] && profile="cuda"
local service_name="invokeai-$profile"
@@ -26,7 +30,7 @@ run() {
printf "%s\n" "starting service $service_name"
docker compose --profile "$profile" up -d "$service_name"
- docker compose logs -f
+ docker compose --profile "$profile" logs -f
}
run
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 00000000000..6240da8b10b
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,21 @@
+# build output
+dist/
+# generated types
+.astro/
+
+# dependencies
+node_modules/
+
+# logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+
+# environment variables
+.env
+.env.production
+
+# macOS-specific files
+.DS_Store
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
deleted file mode 100644
index 24bd5ad7dd2..00000000000
--- a/docs/CHANGELOG.md
+++ /dev/null
@@ -1,815 +0,0 @@
----
-title: Changelog
----
-
-# :octicons-log-16: **Changelog**
-
-## v2.3.5 (22 May 2023)
-
-This release (along with the post1 and post2 follow-on releases) expands support for additional LoRA and LyCORIS models, upgrades diffusers versions, and fixes a few bugs.
-
-### LoRA and LyCORIS Support Improvement
-
- A number of LoRA/LyCORIS fine-tune files (those which alter the text encoder as well as the unet model) were not having the desired effect in InvokeAI. This bug has now been fixed. Full documentation of LoRA support is available at InvokeAI LoRA Support.
- Previously, InvokeAI did not distinguish between LoRA/LyCORIS models based on Stable Diffusion v1.5 vs those based on v2.0 and 2.1, leading to a crash when an incompatible model was loaded. This has now been fixed. In addition, the web pulldown menus for LoRA and Textual Inversion selection have been enhanced to show only those files that are compatible with the currently-selected Stable Diffusion model.
- Support for the newer LoKR LyCORIS files has been added.
-
-### Library Updates and Speed/Reproducibility Advancements
-The major enhancement in this version is that NVIDIA users no longer need to decide between speed and reproducibility. Previously, if you activated the Xformers library, you would see improvements in speed and memory usage, but multiple images generated with the same seed and other parameters would be slightly different from each other. This is no longer the case. Relative to 2.3.5 you will see improved performance when running without Xformers, and even better performance when Xformers is activated. In both cases, images generated with the same settings will be identical.
-
-Here are the new library versions:
-Library Version
-Torch 2.0.0
-Diffusers 0.16.1
-Xformers 0.0.19
-Compel 1.1.5
-Other Improvements
-
-### Performance Improvements
-
- When a model is loaded for the first time, InvokeAI calculates its checksum for incorporation into the PNG metadata. This process could take up to a minute on network-mounted disks and WSL mounts. This release noticeably speeds up the process.
-
-### Bug Fixes
-
- The "import models from directory" and "import from URL" functionality in the console-based model installer has now been fixed.
- When running the WebUI, we have reduced the number of times that InvokeAI reaches out to HuggingFace to fetch the list of embeddable Textual Inversion models. We have also caught and fixed a problem with the updater not correctly detecting when another instance of the updater is running
-
-
-## v2.3.4 (7 April 2023)
-
-What's New in 2.3.4
-
-This features release adds support for LoRA (Low-Rank Adaptation) and LyCORIS (Lora beYond Conventional) models, as well as some minor bug fixes.
-### LoRA and LyCORIS Support
-
-LoRA files contain fine-tuning weights that enable particular styles, subjects or concepts to be applied to generated images. LyCORIS files are an extended variant of LoRA. InvokeAI supports the most common LoRA/LyCORIS format, which ends in the suffix .safetensors. You will find numerous LoRA and LyCORIS models for download at Civitai, and a small but growing number at Hugging Face. Full documentation of LoRA support is available at InvokeAI LoRA Support.( Pre-release note: this page will only be available after release)
-
-To use LoRA/LyCORIS models in InvokeAI:
-
- Download the .safetensors files of your choice and place in /path/to/invokeai/loras. This directory was not present in earlier version of InvokeAI but will be created for you the first time you run the command-line or web client. You can also create the directory manually.
-
- Add withLora(lora-file,weight) to your prompts. The weight is optional and will default to 1.0. A few examples, assuming that a LoRA file named loras/sushi.safetensors is present:
-
-family sitting at dinner table eating sushi withLora(sushi,0.9)
-family sitting at dinner table eating sushi withLora(sushi, 0.75)
-family sitting at dinner table eating sushi withLora(sushi)
-
-Multiple withLora() prompt fragments are allowed. The weight can be arbitrarily large, but the useful range is roughly 0.5 to 1.0. Higher weights make the LoRA's influence stronger. Negative weights are also allowed, which can lead to some interesting effects.
-
- Generate as you usually would! If you find that the image is too "crisp" try reducing the overall CFG value or reducing individual LoRA weights. As is the case with all fine-tunes, you'll get the best results when running the LoRA on top of the model similar to, or identical with, the one that was used during the LoRA's training. Don't try to load a SD 1.x-trained LoRA into a SD 2.x model, and vice versa. This will trigger a non-fatal error message and generation will not proceed.
-
- You can change the location of the loras directory by passing the --lora_directory option to `invokeai.
-
-### New WebUI LoRA and Textual Inversion Buttons
-
-This version adds two new web interface buttons for inserting LoRA and Textual Inversion triggers into the prompt as shown in the screenshot below.
-
-Clicking on one or the other of the buttons will bring up a menu of available LoRA/LyCORIS or Textual Inversion trigger terms. Select a menu item to insert the properly-formatted withLora() or prompt fragment into the positive prompt. The number in parentheses indicates the number of trigger terms currently in the prompt. You may click the button again and deselect the LoRA or trigger to remove it from the prompt, or simply edit the prompt directly.
-
-Currently terms are inserted into the positive prompt textbox only. However, some textual inversion embeddings are designed to be used with negative prompts. To move a textual inversion trigger into the negative prompt, simply cut and paste it.
-
-By default the Textual Inversion menu only shows locally installed models found at startup time in /path/to/invokeai/embeddings. However, InvokeAI has the ability to dynamically download and install additional Textual Inversion embeddings from the HuggingFace Concepts Library. You may choose to display the most popular of these (with five or more likes) in the Textual Inversion menu by going to Settings and turning on "Show Textual Inversions from HF Concepts Library." When this option is activated, the locally-installed TI embeddings will be shown first, followed by uninstalled terms from Hugging Face. See The Hugging Face Concepts Library and Importing Textual Inversion files for more information.
-### Minor features and fixes
-
-This release changes model switching behavior so that the command-line and Web UIs save the last model used and restore it the next time they are launched. It also improves the behavior of the installer so that the pip utility is kept up to date.
-
-### Known Bugs in 2.3.4
-
-These are known bugs in the release.
-
- The Ancestral DPMSolverMultistepScheduler (k_dpmpp_2a) sampler is not yet implemented for diffusers models and will disappear from the WebUI Sampler menu when a diffusers model is selected.
- Windows Defender will sometimes raise Trojan or backdoor alerts for the codeformer.pth face restoration model, as well as the CIDAS/clipseg and runwayml/stable-diffusion-v1.5 models. These are false positives and can be safely ignored. InvokeAI performs a malware scan on all models as they are loaded. For additional security, you should use safetensors models whenever they are available.
-
-
-## v2.3.3 (28 March 2023)
-
-This is a bugfix and minor feature release.
-### Bugfixes
-
-Since version 2.3.2 the following bugs have been fixed:
-Bugs
-
- When using legacy checkpoints with an external VAE, the VAE file is now scanned for malware prior to loading. Previously only the main model weights file was scanned.
- Textual inversion will select an appropriate batchsize based on whether xformers is active, and will default to xformers enabled if the library is detected.
- The batch script log file names have been fixed to be compatible with Windows.
- Occasional corruption of the .next_prefix file (which stores the next output file name in sequence) on Windows systems is now detected and corrected.
- Support loading of legacy config files that have no personalization (textual inversion) section.
- An infinite loop when opening the developer's console from within the invoke.sh script has been corrected.
- Documentation fixes, including a recipe for detecting and fixing problems with the AMD GPU ROCm driver.
-
-Enhancements
-
- It is now possible to load and run several community-contributed SD-2.0 based models, including the often-requested "Illuminati" model.
- The "NegativePrompts" embedding file, and others like it, can now be loaded by placing it in the InvokeAI embeddings directory.
- If no --model is specified at launch time, InvokeAI will remember the last model used and restore it the next time it is launched.
- On Linux systems, the invoke.sh launcher now uses a prettier console-based interface. To take advantage of it, install the dialog package using your package manager (e.g. sudo apt install dialog).
- When loading legacy models (safetensors/ckpt) you can specify a custom config file and/or a VAE by placing like-named files in the same directory as the model following this example:
-
-my-favorite-model.ckpt
-my-favorite-model.yaml
-my-favorite-model.vae.pt # or my-favorite-model.vae.safetensors
-
-### Known Bugs in 2.3.3
-
-These are known bugs in the release.
-
- The Ancestral DPMSolverMultistepScheduler (k_dpmpp_2a) sampler is not yet implemented for diffusers models and will disappear from the WebUI Sampler menu when a diffusers model is selected.
- Windows Defender will sometimes raise Trojan or backdoor alerts for the codeformer.pth face restoration model, as well as the CIDAS/clipseg and runwayml/stable-diffusion-v1.5 models. These are false positives and can be safely ignored. InvokeAI performs a malware scan on all models as they are loaded. For additional security, you should use safetensors models whenever they are available.
-
-
-## v2.3.2 (11 March 2023)
-This is a bugfix and minor feature release.
-
-### Bugfixes
-
-Since version 2.3.1 the following bugs have been fixed:
-
- Black images appearing for potential NSFW images when generating with legacy checkpoint models and both --no-nsfw_checker and --ckpt_convert turned on.
- Black images appearing when generating from models fine-tuned on Stable-Diffusion-2-1-base. When importing V2-derived models, you may be asked to select whether the model was derived from a "base" model (512 pixels) or the 768-pixel SD-2.1 model.
- The "Use All" button was not restoring the Hi-Res Fix setting on the WebUI
- When using the model installer console app, models failed to import correctly when importing from directories with spaces in their names. A similar issue with the output directory was also fixed.
- Crashes that occurred during model merging.
- Restore previous naming of Stable Diffusion base and 768 models.
- Upgraded to latest versions of diffusers, transformers, safetensors and accelerate libraries upstream. We hope that this will fix the assertion NDArray > 2**32 issue that MacOS users have had when generating images larger than 768x768 pixels. Please report back.
-
-As part of the upgrade to diffusers, the location of the diffusers-based models has changed from models/diffusers to models/hub. When you launch InvokeAI for the first time, it will prompt you to OK a one-time move. This should be quick and harmless, but if you have modified your models/diffusers directory in some way, for example using symlinks, you may wish to cancel the migration and make appropriate adjustments.
-New "Invokeai-batch" script
-
-### Invoke AI Batch
-2.3.2 introduces a new command-line only script called invokeai-batch that can be used to generate hundreds of images from prompts and settings that vary systematically. This can be used to try the same prompt across multiple combinations of models, steps, CFG settings and so forth. It also allows you to template prompts and generate a combinatorial list like:
-
-a shack in the mountains, photograph
-a shack in the mountains, watercolor
-a shack in the mountains, oil painting
-a chalet in the mountains, photograph
-a chalet in the mountains, watercolor
-a chalet in the mountains, oil painting
-a shack in the desert, photograph
-...
-
-If you have a system with multiple GPUs, or a single GPU with lots of VRAM, you can parallelize generation across the combinatorial set, reducing wait times and using your system's resources efficiently (make sure you have good GPU cooling).
-
-To try invokeai-batch out. Launch the "developer's console" using the invoke launcher script, or activate the invokeai virtual environment manually. From the console, give the command invokeai-batch --help in order to learn how the script works and create your first template file for dynamic prompt generation.
-
-
-### Known Bugs in 2.3.2
-
-These are known bugs in the release.
-
- The Ancestral DPMSolverMultistepScheduler (k_dpmpp_2a) sampler is not yet implemented for diffusers models and will disappear from the WebUI Sampler menu when a diffusers model is selected.
- Windows Defender will sometimes raise a Trojan alert for the codeformer.pth face restoration model. As far as we have been able to determine, this is a false positive and can be safely whitelisted.
-
-
-## v2.3.1 (22 February 2023)
-This is primarily a bugfix release, but it does provide several new features that will improve the user experience.
-
-### Enhanced support for model management
-
-InvokeAI now makes it convenient to add, remove and modify models. You can individually import models that are stored on your local system, scan an entire folder and its subfolders for models and import them automatically, and even directly import models from the internet by providing their download URLs. You also have the option of designating a local folder to scan for new models each time InvokeAI is restarted.
-
-There are three ways of accessing the model management features:
-
- From the WebUI, click on the cube to the right of the model selection menu. This will bring up a form that allows you to import models individually from your local disk or scan a directory for models to import.
-
- Using the Model Installer App
-
-Choose option (5) download and install models from the invoke launcher script to start a new console-based application for model management. You can use this to select from a curated set of starter models, or import checkpoint, safetensors, and diffusers models from a local disk or the internet. The example below shows importing two checkpoint URLs from popular SD sites and a HuggingFace diffusers model using its Repository ID. It also shows how to designate a folder to be scanned at startup time for new models to import.
-
-Command-line users can start this app using the command invokeai-model-install.
-
- Using the Command Line Client (CLI)
-
-The !install_model and !convert_model commands have been enhanced to allow entering of URLs and local directories to scan and import. The first command installs .ckpt and .safetensors files as-is. The second one converts them into the faster diffusers format before installation.
-
-Internally InvokeAI is able to probe the contents of a .ckpt or .safetensors file to distinguish among v1.x, v2.x and inpainting models. This means that you do not need to include "inpaint" in your model names to use an inpainting model. Note that Stable Diffusion v2.x models will be autoconverted into a diffusers model the first time you use it.
-
-Please see INSTALLING MODELS for more information on model management.
-
-### An Improved Installer Experience
-
-The installer now launches a console-based UI for setting and changing commonly-used startup options:
-
-After selecting the desired options, the installer installs several support models needed by InvokeAI's face reconstruction and upscaling features and then launches the interface for selecting and installing models shown earlier. At any time, you can edit the startup options by launching invoke.sh/invoke.bat and entering option (6) change InvokeAI startup options
-
-Command-line users can launch the new configure app using invokeai-configure.
-
-This release also comes with a renewed updater. To do an update without going through a whole reinstallation, launch invoke.sh or invoke.bat and choose option (9) update InvokeAI . This will bring you to a screen that prompts you to update to the latest released version, to the most current development version, or any released or unreleased version you choose by selecting the tag or branch of the desired version.
-
-Command-line users can run this interface by typing invokeai-configure
-
-### Image Symmetry Options
-
-There are now features to generate horizontal and vertical symmetry during generation. The way these work is to wait until a selected step in the generation process and then to turn on a mirror image effect. In addition to generating some cool images, you can also use this to make side-by-side comparisons of how an image will look with more or fewer steps. Access this option from the WebUI by selecting Symmetry from the image generation settings, or within the CLI by using the options --h_symmetry_time_pct and --v_symmetry_time_pct (these can be abbreviated to --h_sym and --v_sym like all other options).
-
-### A New Unified Canvas Look
-
-This release introduces a beta version of the WebUI Unified Canvas. To try it out, open up the settings dialogue in the WebUI (gear icon) and select Use Canvas Beta Layout:
-
-Refresh the screen and go to to Unified Canvas (left side of screen, third icon from the top). The new layout is designed to provide more space to work in and to keep the image controls close to the image itself:
-
-Model conversion and merging within the WebUI
-
-The WebUI now has an intuitive interface for model merging, as well as for permanent conversion of models from legacy .ckpt/.safetensors formats into diffusers format. These options are also available directly from the invoke.sh/invoke.bat scripts.
-An easier way to contribute translations to the WebUI
-
-We have migrated our translation efforts to Weblate, a FOSS translation product. Maintaining the growing project's translations is now far simpler for the maintainers and community. Please review our brief translation guide for more information on how to contribute.
-Numerous internal bugfixes and performance issues
-
-### Bug Fixes
-This releases quashes multiple bugs that were reported in 2.3.0. Major internal changes include upgrading to diffusers 0.13.0, and using the compel library for prompt parsing. See Detailed Change Log for a detailed list of bugs caught and squished.
-Summary of InvokeAI command line scripts (all accessible via the launcher menu)
-Command Description
-invokeai Command line interface
-invokeai --web Web interface
-invokeai-model-install Model installer with console forms-based front end
-invokeai-ti --gui Textual inversion, with a console forms-based front end
-invokeai-merge --gui Model merging, with a console forms-based front end
-invokeai-configure Startup configuration; can also be used to reinstall support models
-invokeai-update InvokeAI software updater
-
-### Known Bugs in 2.3.1
-
-These are known bugs in the release.
- MacOS users generating 768x768 pixel images or greater using diffusers models may experience a hard crash with assertion NDArray > 2**32 This appears to be an issu...
-
-
-
-## v2.3.0 (15 January 2023)
-
-**Transition to diffusers
-
-Version 2.3 provides support for both the traditional `.ckpt` weight
-checkpoint files as well as the HuggingFace `diffusers` format. This
-introduces several changes you should know about.
-
-1. The models.yaml format has been updated. There are now two
- different type of configuration stanza. The traditional ckpt
- one will look like this, with a `format` of `ckpt` and a
- `weights` field that points to the absolute or ROOTDIR-relative
- location of the ckpt file.
-
- ```
- inpainting-1.5:
- description: RunwayML SD 1.5 model optimized for inpainting (4.27 GB)
- repo_id: runwayml/stable-diffusion-inpainting
- format: ckpt
- width: 512
- height: 512
- weights: models/ldm/stable-diffusion-v1/sd-v1-5-inpainting.ckpt
- config: configs/stable-diffusion/v1-inpainting-inference.yaml
- vae: models/ldm/stable-diffusion-v1/vae-ft-mse-840000-ema-pruned.ckpt
- ```
-
- A configuration stanza for a diffusers model hosted at HuggingFace will look like this,
- with a `format` of `diffusers` and a `repo_id` that points to the
- repository ID of the model on HuggingFace:
-
- ```
- stable-diffusion-2.1:
- description: Stable Diffusion version 2.1 diffusers model (5.21 GB)
- repo_id: stabilityai/stable-diffusion-2-1
- format: diffusers
- ```
-
- A configuration stanza for a diffuers model stored locally should
- look like this, with a `format` of `diffusers`, but a `path` field
- that points at the directory that contains `model_index.json`:
-
- ```
- waifu-diffusion:
- description: Latest waifu diffusion 1.4
- format: diffusers
- path: models/diffusers/hakurei-haifu-diffusion-1.4
- ```
-
-2. In order of precedence, InvokeAI will now use HF_HOME, then
- XDG_CACHE_HOME, then finally default to `ROOTDIR/models` to
- store HuggingFace diffusers models.
-
- Consequently, the format of the models directory has changed to
- mimic the HuggingFace cache directory. When HF_HOME and XDG_HOME
- are not set, diffusers models are now automatically downloaded
- and retrieved from the directory `ROOTDIR/models/diffusers`,
- while other models are stored in the directory
- `ROOTDIR/models/hub`. This organization is the same as that used
- by HuggingFace for its cache management.
-
- This allows you to share diffusers and ckpt model files easily with
- other machine learning applications that use the HuggingFace
- libraries. To do this, set the environment variable HF_HOME
- before starting up InvokeAI to tell it what directory to
- cache models in. To tell InvokeAI to use the standard HuggingFace
- cache directory, you would set HF_HOME like this (Linux/Mac):
-
- `export HF_HOME=~/.cache/huggingface`
-
- Both HuggingFace and InvokeAI will fall back to the XDG_CACHE_HOME
- environment variable if HF_HOME is not set; this path
- takes precedence over `ROOTDIR/models` to allow for the same sharing
- with other machine learning applications that use HuggingFace
- libraries.
-
-3. If you upgrade to InvokeAI 2.3.* from an earlier version, there
- will be a one-time migration from the old models directory format
- to the new one. You will see a message about this the first time
- you start `invoke.py`.
-
-4. Both the front end back ends of the model manager have been
- rewritten to accommodate diffusers. You can import models using
- their local file path, using their URLs, or their HuggingFace
- repo_ids. On the command line, all these syntaxes work:
-
- ```
- !import_model stabilityai/stable-diffusion-2-1-base
- !import_model /opt/sd-models/sd-1.4.ckpt
- !import_model https://huggingface.co/Fictiverse/Stable_Diffusion_PaperCut_Model/blob/main/PaperCut_v1.ckpt
- ```
-
-**KNOWN BUGS (15 January 2023)
-
-1. On CUDA systems, the 768 pixel stable-diffusion-2.0 and
- stable-diffusion-2.1 models can only be run as `diffusers` models
- when the `xformer` library is installed and configured. Without
- `xformers`, InvokeAI returns black images.
-
-2. Inpainting and outpainting have regressed in quality.
-
-Both these issues are being actively worked on.
-
-## v2.2.4 (11 December 2022)
-
-**the `invokeai` directory**
-
-Previously there were two directories to worry about, the directory that
-contained the InvokeAI source code and the launcher scripts, and the `invokeai`
-directory that contained the models files, embeddings, configuration and
-outputs. With the 2.2.4 release, this dual system is done away with, and
-everything, including the `invoke.bat` and `invoke.sh` launcher scripts, now
-live in a directory named `invokeai`. By default this directory is located in
-your home directory (e.g. `\Users\yourname` on Windows), but you can select
-where it goes at install time.
-
-After installation, you can delete the install directory (the one that the zip
-file creates when it unpacks). Do **not** delete or move the `invokeai`
-directory!
-
-**Initialization file `invokeai/invokeai.init`**
-
-You can place frequently-used startup options in this file, such as the default
-number of steps or your preferred sampler. To keep everything in one place, this
-file has now been moved into the `invokeai` directory and is named
-`invokeai.init`.
-
-**To update from Version 2.2.3**
-
-The easiest route is to download and unpack one of the 2.2.4 installer files.
-When it asks you for the location of the `invokeai` runtime directory, respond
-with the path to the directory that contains your 2.2.3 `invokeai`. That is, if
-`invokeai` lives at `C:\Users\fred\invokeai`, then answer with `C:\Users\fred`
-and answer "Y" when asked if you want to reuse the directory.
-
-The `update.sh` (`update.bat`) script that came with the 2.2.3 source installer
-does not know about the new directory layout and won't be fully functional.
-
-**To update to 2.2.5 (and beyond) there's now an update path**
-
-As they become available, you can update to more recent versions of InvokeAI
-using an `update.sh` (`update.bat`) script located in the `invokeai` directory.
-Running it without any arguments will install the most recent version of
-InvokeAI. Alternatively, you can get set releases by running the `update.sh`
-script with an argument in the command shell. This syntax accepts the path to
-the desired release's zip file, which you can find by clicking on the green
-"Code" button on this repository's home page.
-
-**Other 2.2.4 Improvements**
-
-- Fix InvokeAI GUI initialization by @addianto in #1687
-- fix link in documentation by @lstein in #1728
-- Fix broken link by @ShawnZhong in #1736
-- Remove reference to binary installer by @lstein in #1731
-- documentation fixes for 2.2.3 by @lstein in #1740
-- Modify installer links to point closer to the source installer by @ebr in
- #1745
-- add documentation warning about 1650/60 cards by @lstein in #1753
-- Fix Linux source URL in installation docs by @andybearman in #1756
-- Make install instructions discoverable in readme by @damian0815 in #1752
-- typo fix by @ofirkris in #1755
-- Non-interactive model download (support HUGGINGFACE_TOKEN) by @ebr in #1578
-- fix(srcinstall): shell installer - cp scripts instead of linking by @tildebyte
- in #1765
-- stability and usage improvements to binary & source installers by @lstein in
- #1760
-- fix off-by-one bug in cross-attention-control by @damian0815 in #1774
-- Eventually update APP_VERSION to 2.2.3 by @spezialspezial in #1768
-- invoke script cds to its location before running by @lstein in #1805
-- Make PaperCut and VoxelArt models load again by @lstein in #1730
-- Fix --embedding_directory / --embedding_path not working by @blessedcoolant in
- #1817
-- Clean up readme by @hipsterusername in #1820
-- Optimized Docker build with support for external working directory by @ebr in
- #1544
-- disable pushing the cloud container by @mauwii in #1831
-- Fix docker push github action and expand with additional metadata by @ebr in
- #1837
-- Fix Broken Link To Notebook by @VedantMadane in #1821
-- Account for flat models by @spezialspezial in #1766
-- Update invoke.bat.in isolate environment variables by @lynnewu in #1833
-- Arch Linux Specific PatchMatch Instructions & fixing conda install on linux by
- @SammCheese in #1848
-- Make force free GPU memory work in img2img by @addianto in #1844
-- New installer by @lstein
-
-## v2.2.3 (2 December 2022)
-
-!!! Note
-
- This point release removes references to the binary installer from the
- installation guide. The binary installer is not stable at the current
- time. First time users are encouraged to use the "source" installer as
- described in [Installing InvokeAI with the Source Installer](installation/deprecated_documentation/INSTALL_SOURCE.md)
-
-With InvokeAI 2.2, this project now provides enthusiasts and professionals a
-robust workflow solution for creating AI-generated and human facilitated
-compositions. Additional enhancements have been made as well, improving safety,
-ease of use, and installation.
-
-Optimized for efficiency, InvokeAI needs only ~3.5GB of VRAM to generate a
-512x768 image (and less for smaller images), and is compatible with
-Windows/Linux/Mac (M1 & M2).
-
-You can see the [release video](https://youtu.be/hIYBfDtKaus) here, which
-introduces the main WebUI enhancement for version 2.2 -
-[The Unified Canvas](features/UNIFIED_CANVAS.md). This new workflow is the
-biggest enhancement added to the WebUI to date, and unlocks a stunning amount of
-potential for users to create and iterate on their creations. The following
-sections describe what's new for InvokeAI.
-
-## v2.2.2 (30 November 2022)
-
-!!! note
-
- The binary installer is not ready for prime time. First time users are recommended to install via the "source" installer accessible through the links at the bottom of this page.****
-
-With InvokeAI 2.2, this project now provides enthusiasts and professionals a
-robust workflow solution for creating AI-generated and human facilitated
-compositions. Additional enhancements have been made as well, improving safety,
-ease of use, and installation.
-
-Optimized for efficiency, InvokeAI needs only ~3.5GB of VRAM to generate a
-512x768 image (and less for smaller images), and is compatible with
-Windows/Linux/Mac (M1 & M2).
-
-You can see the [release video](https://youtu.be/hIYBfDtKaus) here, which
-introduces the main WebUI enhancement for version 2.2 -
-[The Unified Canvas](https://invoke-ai.github.io/InvokeAI/features/UNIFIED_CANVAS/).
-This new workflow is the biggest enhancement added to the WebUI to date, and
-unlocks a stunning amount of potential for users to create and iterate on their
-creations. The following sections describe what's new for InvokeAI.
-
-## v2.2.0 (2 December 2022)
-
-With InvokeAI 2.2, this project now provides enthusiasts and professionals a
-robust workflow solution for creating AI-generated and human facilitated
-compositions. Additional enhancements have been made as well, improving safety,
-ease of use, and installation.
-
-Optimized for efficiency, InvokeAI needs only ~3.5GB of VRAM to generate a
-512x768 image (and less for smaller images), and is compatible with
-Windows/Linux/Mac (M1 & M2).
-
-You can see the [release video](https://youtu.be/hIYBfDtKaus) here, which
-introduces the main WebUI enhancement for version 2.2 -
-[The Unified Canvas](features/UNIFIED_CANVAS.md). This new workflow is the
-biggest enhancement added to the WebUI to date, and unlocks a stunning amount of
-potential for users to create and iterate on their creations. The following
-sections describe what's new for InvokeAI.
-
-## v2.1.3 (13 November 2022)
-
-- A choice of installer scripts that automate installation and configuration.
- See
- [Installation](installation/INSTALLATION.md).
-- A streamlined manual installation process that works for both Conda and
- PIP-only installs. See
- [Manual Installation](installation/020_INSTALL_MANUAL.md).
-- The ability to save frequently-used startup options (model to load, steps,
- sampler, etc) in a `.invokeai` file. See
- [Client](deprecated/CLI.md)
-- Support for AMD GPU cards (non-CUDA) on Linux machines.
-- Multiple bugs and edge cases squashed.
-
-## v2.1.0 (2 November 2022)
-
-- update mac instructions to use invokeai for env name by @willwillems in #1030
-- Update .gitignore by @blessedcoolant in #1040
-- reintroduce fix for m1 from #579 missing after merge by @skurovec in #1056
-- Update Stable_Diffusion_AI_Notebook.ipynb (Take 2) by @ChloeL19 in #1060
-- Print out the device type which is used by @manzke in #1073
-- Hires Addition by @hipsterusername in #1063
-- fix for "1 leaked semaphore objects to clean up at shutdown" on M1 by
- @skurovec in #1081
-- Forward dream.py to invoke.py using the same interpreter, add deprecation
- warning by @db3000 in #1077
-- fix noisy images at high step counts by @lstein in #1086
-- Generalize facetool strength argument by @db3000 in #1078
-- Enable fast switching among models at the invoke> command line by @lstein in
- #1066
-- Fix Typo, committed changing ldm environment to invokeai by @jdries3 in #1095
-- Update generate.py by @unreleased in #1109
-- Update 'ldm' env to 'invokeai' in troubleshooting steps by @19wolf in #1125
-- Fixed documentation typos and resolved merge conflicts by @rupeshs in #1123
-- Fix broken doc links, fix malaprop in the project subtitle by @majick in #1131
-- Only output facetool parameters if enhancing faces by @db3000 in #1119
-- Update gitignore to ignore codeformer weights at new location by
- @spezialspezial in #1136
-- fix links to point to invoke-ai.github.io #1117 by @mauwii in #1143
-- Rework-mkdocs by @mauwii in #1144
-- add option to CLI and pngwriter that allows user to set PNG compression level
- by @lstein in #1127
-- Fix img2img DDIM index out of bound by @wfng92 in #1137
-- Fix gh actions by @mauwii in #1128
-- update mac instructions to use invokeai for env name by @willwillems in #1030
-- Update .gitignore by @blessedcoolant in #1040
-- reintroduce fix for m1 from #579 missing after merge by @skurovec in #1056
-- Update Stable_Diffusion_AI_Notebook.ipynb (Take 2) by @ChloeL19 in #1060
-- Print out the device type which is used by @manzke in #1073
-- Hires Addition by @hipsterusername in #1063
-- fix for "1 leaked semaphore objects to clean up at shutdown" on M1 by
- @skurovec in #1081
-- Forward dream.py to invoke.py using the same interpreter, add deprecation
- warning by @db3000 in #1077
-- fix noisy images at high step counts by @lstein in #1086
-- Generalize facetool strength argument by @db3000 in #1078
-- Enable fast switching among models at the invoke> command line by @lstein in
- #1066
-- Fix Typo, committed changing ldm environment to invokeai by @jdries3 in #1095
-- Fixed documentation typos and resolved merge conflicts by @rupeshs in #1123
-- Only output facetool parameters if enhancing faces by @db3000 in #1119
-- add option to CLI and pngwriter that allows user to set PNG compression level
- by @lstein in #1127
-- Fix img2img DDIM index out of bound by @wfng92 in #1137
-- Add text prompt to inpaint mask support by @lstein in #1133
-- Respect http[s] protocol when making socket.io middleware by @damian0815 in
- #976
-- WebUI: Adds Codeformer support by @psychedelicious in #1151
-- Skips normalizing prompts for web UI metadata by @psychedelicious in #1165
-- Add Asymmetric Tiling by @carson-katri in #1132
-- Web UI: Increases max CFG Scale to 200 by @psychedelicious in #1172
-- Corrects color channels in face restoration; Fixes #1167 by @psychedelicious
- in #1175
-- Flips channels using array slicing instead of using OpenCV by @psychedelicious
- in #1178
-- Fix typo in docs: s/Formally/Formerly by @noodlebox in #1176
-- fix clipseg loading problems by @lstein in #1177
-- Correct color channels in upscale using array slicing by @wfng92 in #1181
-- Web UI: Filters existing images when adding new images; Fixes #1085 by
- @psychedelicious in #1171
-- fix a number of bugs in textual inversion by @lstein in #1190
-- Improve !fetch, add !replay command by @ArDiouscuros in #882
-- Fix generation of image with s>1000 by @holstvoogd in #951
-- Web UI: Gallery improvements by @psychedelicious in #1198
-- Update CLI.md by @krummrey in #1211
-- outcropping improvements by @lstein in #1207
-- add support for loading VAE autoencoders by @lstein in #1216
-- remove duplicate fix_func for MPS by @wfng92 in #1210
-- Metadata storage and retrieval fixes by @lstein in #1204
-- nix: add shell.nix file by @Cloudef in #1170
-- Web UI: Changes vite dist asset paths to relative by @psychedelicious in #1185
-- Web UI: Removes isDisabled from PromptInput by @psychedelicious in #1187
-- Allow user to generate images with initial noise as on M1 / mps system by
- @ArDiouscuros in #981
-- feat: adding filename format template by @plucked in #968
-- Web UI: Fixes broken bundle by @psychedelicious in #1242
-- Support runwayML custom inpainting model by @lstein in #1243
-- Update IMG2IMG.md by @talitore in #1262
-- New dockerfile - including a build- and a run- script as well as a GH-Action
- by @mauwii in #1233
-- cut over from karras to model noise schedule for higher steps by @lstein in
- #1222
-- Prompt tweaks by @lstein in #1268
-- Outpainting implementation by @Kyle0654 in #1251
-- fixing aspect ratio on hires by @tjennings in #1249
-- Fix-build-container-action by @mauwii in #1274
-- handle all unicode characters by @damian0815 in #1276
-- adds models.user.yml to .gitignore by @JakeHL in #1281
-- remove debug branch, set fail-fast to false by @mauwii in #1284
-- Protect-secrets-on-pr by @mauwii in #1285
-- Web UI: Adds initial inpainting implementation by @psychedelicious in #1225
-- fix environment-mac.yml - tested on x64 and arm64 by @mauwii in #1289
-- Use proper authentication to download model by @mauwii in #1287
-- Prevent indexing error for mode RGB by @spezialspezial in #1294
-- Integrate sd-v1-5 model into test matrix (easily expandable), remove
- unecesarry caches by @mauwii in #1293
-- add --no-interactive to configure_invokeai step by @mauwii in #1302
-- 1-click installer and updater. Uses micromamba to install git and conda into a
- contained environment (if necessary) before running the normal installation
- script by @cmdr2 in #1253
-- configure_invokeai.py script downloads the weight files by @lstein in #1290
-
-## v2.0.1 (13 October 2022)
-
-- fix noisy images at high step count when using k\* samplers
-- dream.py script now calls invoke.py module directly rather than via a new
- python process (which could break the environment)
-
-## v2.0.0 (9 October 2022)
-
-- `dream.py` script renamed `invoke.py`. A `dream.py` script wrapper remains for
- backward compatibility.
-- Completely new WebGUI - launch with `python3 scripts/invoke.py --web`
-- img2img runs on all k\* samplers
-- Support for
- [negative prompts](features/PROMPTS.md#negative-and-unconditioned-prompts)
-- Support for CodeFormer face reconstruction
-- Support for Textual Inversion on Macintoshes
-- Support in both WebGUI and CLI for
- [post-processing of previously-generated images](features/POSTPROCESS.md)
- using facial reconstruction, ESRGAN upscaling, outcropping (similar to DALL-E
- infinite canvas), and "embiggen" upscaling. See the `!fix` command.
-- New `--hires` option on `invoke>` line allows
- [larger images to be created without duplicating elements](deprecated/CLI.md#this-is-an-example-of-txt2img),
- at the cost of some performance.
-- New `--perlin` and `--threshold` options allow you to add and control
- variation during image generation (see
- [Thresholding and Perlin Noise Initialization](features/OTHER.md#thresholding-and-perlin-noise-initialization-options))
-- Extensive metadata now written into PNG files, allowing reliable regeneration
- of images and tweaking of previous settings.
-- Command-line completion in `invoke.py` now works on Windows, Linux and Mac
- platforms.
-- Improved [command-line completion behavior](deprecated/CLI.md) New commands
- added:
- - List command-line history with `!history`
- - Search command-line history with `!search`
- - Clear history with `!clear`
-- Deprecated `--full_precision` / `-F`. Simply omit it and `invoke.py` will auto
- configure. To switch away from auto use the new flag like
- `--precision=float32`.
-
-## v1.14 (11 September 2022)
-
-- Memory optimizations for small-RAM cards. 512x512 now possible on 4 GB GPUs.
-- Full support for Apple hardware with M1 or M2 chips.
-- Add "seamless mode" for circular tiling of image. Generates beautiful effects.
- ([prixt](https://github.com/prixt)).
-- Inpainting support.
-- Improved web server GUI.
-- Lots of code and documentation cleanups.
-
-## v1.13 (3 September 2022)
-
-- Support image variations (see [VARIATIONS](deprecated/VARIATIONS.md)
- ([Kevin Gibbons](https://github.com/bakkot) and many contributors and
- reviewers)
-- Supports a Google Colab notebook for a standalone server running on Google
- hardware [Arturo Mendivil](https://github.com/artmen1516)
-- WebUI supports GFPGAN/ESRGAN facial reconstruction and upscaling
- [Kevin Gibbons](https://github.com/bakkot)
-- WebUI supports incremental display of in-progress images during generation
- [Kevin Gibbons](https://github.com/bakkot)
-- A new configuration file scheme that allows new models (including upcoming
- stable-diffusion-v1.5) to be added without altering the code.
- ([David Wager](https://github.com/maddavid12))
-- Can specify --grid on invoke.py command line as the default.
-- Miscellaneous internal bug and stability fixes.
-- Works on M1 Apple hardware.
-- Multiple bug fixes.
-
----
-
-## v1.12 (28 August 2022)
-
-- Improved file handling, including ability to read prompts from standard input.
- (kudos to [Yunsaki](https://github.com/yunsaki)
-- The web server is now integrated with the invoke.py script. Invoke by adding
- --web to the invoke.py command arguments.
-- Face restoration and upscaling via GFPGAN and Real-ESGAN are now automatically
- enabled if the GFPGAN directory is located as a sibling to Stable Diffusion.
- VRAM requirements are modestly reduced. Thanks to both
- [Blessedcoolant](https://github.com/blessedcoolant) and
- [Oceanswave](https://github.com/oceanswave) for their work on this.
-- You can now swap samplers on the invoke> command line.
- [Blessedcoolant](https://github.com/blessedcoolant)
-
----
-
-## v1.11 (26 August 2022)
-
-- NEW FEATURE: Support upscaling and face enhancement using the GFPGAN module.
- (kudos to [Oceanswave](https://github.com/Oceanswave)
-- You now can specify a seed of -1 to use the previous image's seed, -2 to use
- the seed for the image generated before that, etc. Seed memory only extends
- back to the previous command, but will work on all images generated with the
- -n# switch.
-- Variant generation support temporarily disabled pending more general solution.
-- Created a feature branch named **yunsaki-morphing-invoke** which adds
- experimental support for iteratively modifying the prompt and its parameters.
- Please
- see[Pull Request #86](https://github.com/lstein/stable-diffusion/pull/86) for
- a synopsis of how this works. Note that when this feature is eventually added
- to the main branch, it will may be modified significantly.
-
----
-
-## v1.10 (25 August 2022)
-
-- A barebones but fully functional interactive web server for online generation
- of txt2img and img2img.
-
----
-
-## v1.09 (24 August 2022)
-
-- A new -v option allows you to generate multiple variants of an initial image
- in img2img mode. (kudos to [Oceanswave](https://github.com/Oceanswave).
- [ See this discussion in the PR for examples and details on use](https://github.com/lstein/stable-diffusion/pull/71#issuecomment-1226700810))
-- Added ability to personalize text to image generation (kudos to
- [Oceanswave](https://github.com/Oceanswave) and
- [nicolai256](https://github.com/nicolai256))
-- Enabled all of the samplers from k_diffusion
-
----
-
-## v1.08 (24 August 2022)
-
-- Escape single quotes on the invoke> command before trying to parse. This
- avoids parse errors.
-- Removed instruction to get Python3.8 as first step in Windows install.
- Anaconda3 does it for you.
-- Added bounds checks for numeric arguments that could cause crashes.
-- Cleaned up the copyright and license agreement files.
-
----
-
-## v1.07 (23 August 2022)
-
-- Image filenames will now never fill gaps in the sequence, but will be assigned
- the next higher name in the chosen directory. This ensures that the alphabetic
- and chronological sort orders are the same.
-
----
-
-## v1.06 (23 August 2022)
-
-- Added weighted prompt support contributed by
- [xraxra](https://github.com/xraxra)
-- Example of using weighted prompts to tweak a demonic figure contributed by
- [bmaltais](https://github.com/bmaltais)
-
----
-
-## v1.05 (22 August 2022 - after the drop)
-
-- Filenames now use the following formats: 000010.95183149.png -- Two files
- produced by the same command (e.g. -n2), 000010.26742632.png -- distinguished
- by a different seed.
-
- 000011.455191342.01.png -- Two files produced by the same command using
- 000011.455191342.02.png -- a batch size>1 (e.g. -b2). They have the same seed.
-
- 000011.4160627868.grid#1-4.png -- a grid of four images (-g); the whole grid
- can be regenerated with the indicated key
-
-- It should no longer be possible for one image to overwrite another
-- You can use the "cd" and "pwd" commands at the invoke> prompt to set and
- retrieve the path of the output directory.
-
----
-
-## v1.04 (22 August 2022 - after the drop)
-
-- Updated README to reflect installation of the released weights.
-- Suppressed very noisy and inconsequential warning when loading the frozen CLIP
- tokenizer.
-
----
-
-## v1.03 (22 August 2022)
-
-- The original txt2img and img2img scripts from the CompViz repository have been
- moved into a subfolder named "orig_scripts", to reduce confusion.
-
----
-
-## v1.02 (21 August 2022)
-
-- A copy of the prompt and all of its switches and options is now stored in the
- corresponding image in a tEXt metadata field named "Dream". You can read the
- prompt using scripts/images2prompt.py, or an image editor that allows you to
- explore the full metadata. **Please run "conda env update" to load the k_lms
- dependencies!!**
-
----
-
-## v1.01 (21 August 2022)
-
-- added k_lms sampling. **Please run "conda env update" to load the k_lms
- dependencies!!**
-- use half precision arithmetic by default, resulting in faster execution and
- lower memory requirements Pass argument --full_precision to invoke.py to get
- slower but more accurate image generation
-
----
-
-## Links
-
-- **[Read Me](index.md)**
diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md
deleted file mode 100644
index d68cdf98c83..00000000000
--- a/docs/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-* Demonstrating empathy and kindness toward other people
-* Being respectful of differing opinions, viewpoints, and experiences
-* Giving and gracefully accepting constructive feedback
-* Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-* Focusing on what is best not just for us as individuals, but for the
- overall community
-
-Examples of unacceptable behavior include:
-
-* The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-* Trolling, insulting or derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information, such as a physical or email
- address, without their explicit permission
-* Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for moderation
-decisions when appropriate.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior
-may be reported to the community leaders responsible for enforcement
-at https://github.com/invoke-ai/InvokeAI/issues. All complaints will
-be reviewed and investigated promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series
-of actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.0, available at
-https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
-
-Community Impact Guidelines were inspired by [Mozilla's code of conduct
-enforcement ladder](https://github.com/mozilla/diversity).
-
-[homepage]: https://www.contributor-covenant.org
-
-For answers to common questions about this code of conduct, see the FAQ at
-https://www.contributor-covenant.org/faq. Translations are available at
-https://www.contributor-covenant.org/translations.
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 00000000000..591a5c353f9
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1 @@
+# Invoke AI Documentation
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
deleted file mode 100644
index 56d27eb76ce..00000000000
--- a/docs/RELEASE.md
+++ /dev/null
@@ -1,173 +0,0 @@
-# Release Process
-
-The app is published in twice, in different build formats.
-
-- A [PyPI] distribution. This includes both a source distribution and built distribution (a wheel). Users install with `pip install invokeai`. The updater uses this build.
-- An installer on the [InvokeAI Releases Page]. This is a zip file with install scripts and a wheel. This is only used for new installs.
-
-## General Prep
-
-Make a developer call-out for PRs to merge. Merge and test things out.
-
-While the release workflow does not include end-to-end tests, it does pause before publishing so you can download and test the final build.
-
-## Release Workflow
-
-The `release.yml` workflow runs a number of jobs to handle code checks, tests, build and publish on PyPI.
-
-It is triggered on **tag push**, when the tag matches `v*`. It doesn't matter if you've prepped a release branch like `release/v3.5.0` or are releasing from `main` - it works the same.
-
-> Because commits are reference-counted, it is safe to create a release branch, tag it, let the workflow run, then delete the branch. So long as the tag exists, that commit will exist.
-
-### Triggering the Workflow
-
-Run `make tag-release` to tag the current commit and kick off the workflow.
-
-The release may also be dispatched [manually].
-
-### Workflow Jobs and Process
-
-The workflow consists of a number of concurrently-run jobs, and two final publish jobs.
-
-The publish jobs require manual approval and are only run if the other jobs succeed.
-
-#### `check-version` Job
-
-This job checks that the git ref matches the app version. It matches the ref against the `__version__` variable in `invokeai/version/invokeai_version.py`.
-
-When the workflow is triggered by tag push, the ref is the tag. If the workflow is run manually, the ref is the target selected from the **Use workflow from** dropdown.
-
-This job uses [samuelcolvin/check-python-version].
-
-> Any valid [version specifier] works, so long as the tag matches the version. The release workflow works exactly the same for `RC`, `post`, `dev`, etc.
-
-#### Check and Test Jobs
-
-- **`python-tests`**: runs `pytest` on matrix of platforms
-- **`python-checks`**: runs `ruff` (format and lint)
-- **`frontend-tests`**: runs `vitest`
-- **`frontend-checks`**: runs `prettier` (format), `eslint` (lint), `dpdm` (circular refs), `tsc` (static type check) and `knip` (unused imports)
-
-> **TODO** We should add `mypy` or `pyright` to the **`check-python`** job.
-
-> **TODO** We should add an end-to-end test job that generates an image.
-
-#### `build-installer` Job
-
-This sets up both python and frontend dependencies and builds the python package. Internally, this runs `installer/create_installer.sh` and uploads two artifacts:
-
-- **`dist`**: the python distribution, to be published on PyPI
-- **`InvokeAI-installer-${VERSION}.zip`**: the installer to be included in the GitHub release
-
-#### Sanity Check & Smoke Test
-
-At this point, the release workflow pauses as the remaining publish jobs require approval. Time to test the installer.
-
-Because the installer pulls from PyPI, and we haven't published to PyPI yet, you will need to install from the wheel:
-
-- Download and unzip `dist.zip` and the installer from the **Summary** tab of the workflow
-- Run the installer script using the `--wheel` CLI arg, pointing at the wheel:
-
- ```sh
- ./install.sh --wheel ../InvokeAI-4.0.0rc6-py3-none-any.whl
- ```
-
-- Install to a temporary directory so you get the new user experience
-- Download a model and generate
-
-> The same wheel file is bundled in the installer and in the `dist` artifact, which is uploaded to PyPI. You should end up with the exactly the same installation as if the installer got the wheel from PyPI.
-
-##### Something isn't right
-
-If testing reveals any issues, no worries. Cancel the workflow, which will cancel the pending publish jobs (you didn't approve them prematurely, right?).
-
-Now you can start from the top:
-
-- Fix the issues and PR the fixes per usual
-- Get the PR approved and merged per usual
-- Switch to `main` and pull in the fixes
-- Run `make tag-release` to move the tag to `HEAD` (which has the fixes) and kick off the release workflow again
-- Re-do the sanity check
-
-#### PyPI Publish Jobs
-
-The publish jobs will run if any of the previous jobs fail.
-
-They use [GitHub environments], which are configured as [trusted publishers] on PyPI.
-
-Both jobs require a maintainer to approve them from the workflow's **Summary** tab.
-
-- Click the **Review deployments** button
-- Select the environment (either `testpypi` or `pypi`)
-- Click **Approve and deploy**
-
-> **If the version already exists on PyPI, the publish jobs will fail.** PyPI only allows a given version to be published once - you cannot change it. If version published on PyPI has a problem, you'll need to "fail forward" by bumping the app version and publishing a followup release.
-
-##### Failing PyPI Publish
-
-Check the [python infrastructure status page] for incidents.
-
-If there are no incidents, contact @hipsterusername or @lstein, who have owner access to GH and PyPI, to see if access has expired or something like that.
-
-#### `publish-testpypi` Job
-
-Publishes the distribution on the [Test PyPI] index, using the `testpypi` GitHub environment.
-
-This job is not required for the production PyPI publish, but included just in case you want to test the PyPI release.
-
-If approved and successful, you could try out the test release like this:
-
-```sh
-# Create a new virtual environment
-python -m venv ~/.test-invokeai-dist --prompt test-invokeai-dist
-# Install the distribution from Test PyPI
-pip install --index-url https://test.pypi.org/simple/ invokeai
-# Run and test the app
-invokeai-web
-# Cleanup
-deactivate
-rm -rf ~/.test-invokeai-dist
-```
-
-#### `publish-pypi` Job
-
-Publishes the distribution on the production PyPI index, using the `pypi` GitHub environment.
-
-## Publish the GitHub Release with installer
-
-Once the release is published to PyPI, it's time to publish the GitHub release.
-
-1. [Draft a new release] on GitHub, choosing the tag that triggered the release.
-1. Write the release notes, describing important changes. The **Generate release notes** button automatically inserts the changelog and new contributors, and you can copy/paste the intro from previous releases.
-1. Use `scripts/get_external_contributions.py` to get a list of external contributions to shout out in the release notes.
-1. Upload the zip file created in **`build`** job into the Assets section of the release notes.
-1. Check **Set as a pre-release** if it's a pre-release.
-1. Check **Create a discussion for this release**.
-1. Publish the release.
-1. Announce the release in Discord.
-
-> **TODO** Workflows can create a GitHub release from a template and upload release assets. One popular action to handle this is [ncipollo/release-action]. A future enhancement to the release process could set this up.
-
-## Manual Build
-
-The `build installer` workflow can be dispatched manually. This is useful to test the installer for a given branch or tag.
-
-No checks are run, it just builds.
-
-## Manual Release
-
-The `release` workflow can be dispatched manually. You must dispatch the workflow from the right tag, else it will fail the version check.
-
-This functionality is available as a fallback in case something goes wonky. Typically, releases should be triggered via tag push as described above.
-
-[InvokeAI Releases Page]: https://github.com/invoke-ai/InvokeAI/releases
-[PyPI]: https://pypi.org/
-[Draft a new release]: https://github.com/invoke-ai/InvokeAI/releases/new
-[Test PyPI]: https://test.pypi.org/
-[version specifier]: https://packaging.python.org/en/latest/specifications/version-specifiers/
-[ncipollo/release-action]: https://github.com/ncipollo/release-action
-[GitHub environments]: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
-[trusted publishers]: https://docs.pypi.org/trusted-publishers/
-[samuelcolvin/check-python-version]: https://github.com/samuelcolvin/check-python-version
-[manually]: #manual-release
-[python infrastructure status page]: https://status.python.org/
diff --git a/docs/assets/Lincoln-and-Parrot-512-transparent.png b/docs/assets/Lincoln-and-Parrot-512-transparent.png
deleted file mode 100755
index 363f3cced3f..00000000000
Binary files a/docs/assets/Lincoln-and-Parrot-512-transparent.png and /dev/null differ
diff --git a/docs/assets/Lincoln-and-Parrot-512.png b/docs/assets/Lincoln-and-Parrot-512.png
deleted file mode 100644
index acabe0f27c1..00000000000
Binary files a/docs/assets/Lincoln-and-Parrot-512.png and /dev/null differ
diff --git a/docs/assets/canvas/biker_granny.png b/docs/assets/canvas/biker_granny.png
deleted file mode 100644
index 70385014da8..00000000000
Binary files a/docs/assets/canvas/biker_granny.png and /dev/null differ
diff --git a/docs/assets/canvas/biker_jacket_granny.png b/docs/assets/canvas/biker_jacket_granny.png
deleted file mode 100644
index 3a46b8a49ce..00000000000
Binary files a/docs/assets/canvas/biker_jacket_granny.png and /dev/null differ
diff --git a/docs/assets/canvas/mask_granny.png b/docs/assets/canvas/mask_granny.png
deleted file mode 100644
index 041a0317c92..00000000000
Binary files a/docs/assets/canvas/mask_granny.png and /dev/null differ
diff --git a/docs/assets/canvas/staging_area.png b/docs/assets/canvas/staging_area.png
deleted file mode 100644
index 0e9d4ba0de9..00000000000
Binary files a/docs/assets/canvas/staging_area.png and /dev/null differ
diff --git a/docs/assets/canvas_preview.png b/docs/assets/canvas_preview.png
deleted file mode 100644
index dba4ee2ca29..00000000000
Binary files a/docs/assets/canvas_preview.png and /dev/null differ
diff --git a/docs/assets/colab_notebook.png b/docs/assets/colab_notebook.png
deleted file mode 100644
index 933664a86f7..00000000000
Binary files a/docs/assets/colab_notebook.png and /dev/null differ
diff --git a/docs/assets/concepts/image1.png b/docs/assets/concepts/image1.png
deleted file mode 100644
index f8c93efcf93..00000000000
Binary files a/docs/assets/concepts/image1.png and /dev/null differ
diff --git a/docs/assets/concepts/image2.png b/docs/assets/concepts/image2.png
deleted file mode 100644
index a22411492e7..00000000000
Binary files a/docs/assets/concepts/image2.png and /dev/null differ
diff --git a/docs/assets/concepts/image3.png b/docs/assets/concepts/image3.png
deleted file mode 100644
index e2213fc7079..00000000000
Binary files a/docs/assets/concepts/image3.png and /dev/null differ
diff --git a/docs/assets/concepts/image4.png b/docs/assets/concepts/image4.png
deleted file mode 100644
index 052479019c1..00000000000
Binary files a/docs/assets/concepts/image4.png and /dev/null differ
diff --git a/docs/assets/concepts/image5.png b/docs/assets/concepts/image5.png
deleted file mode 100644
index f3a4f764705..00000000000
Binary files a/docs/assets/concepts/image5.png and /dev/null differ
diff --git a/docs/assets/control-panel-2.png b/docs/assets/control-panel-2.png
deleted file mode 100644
index d7767d524a4..00000000000
Binary files a/docs/assets/control-panel-2.png and /dev/null differ
diff --git a/docs/assets/dream-py-demo.png b/docs/assets/dream-py-demo.png
deleted file mode 100644
index c6945bf07c9..00000000000
Binary files a/docs/assets/dream-py-demo.png and /dev/null differ
diff --git a/docs/assets/dream_web_server.png b/docs/assets/dream_web_server.png
deleted file mode 100644
index c8ec9756c59..00000000000
Binary files a/docs/assets/dream_web_server.png and /dev/null differ
diff --git a/docs/assets/features/restoration-montage.png b/docs/assets/features/restoration-montage.png
deleted file mode 100644
index 825a89a8dd2..00000000000
Binary files a/docs/assets/features/restoration-montage.png and /dev/null differ
diff --git a/docs/assets/features/upscale-dialog.png b/docs/assets/features/upscale-dialog.png
deleted file mode 100644
index fd91f90a65f..00000000000
Binary files a/docs/assets/features/upscale-dialog.png and /dev/null differ
diff --git a/docs/assets/features/upscaling-montage.png b/docs/assets/features/upscaling-montage.png
deleted file mode 100644
index 6b3eeba3473..00000000000
Binary files a/docs/assets/features/upscaling-montage.png and /dev/null differ
diff --git a/docs/assets/img2img/000019.1592514025.png b/docs/assets/img2img/000019.1592514025.png
deleted file mode 100644
index 2bc2d63ffa0..00000000000
Binary files a/docs/assets/img2img/000019.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000019.steps.png b/docs/assets/img2img/000019.steps.png
deleted file mode 100644
index 28899e91111..00000000000
Binary files a/docs/assets/img2img/000019.steps.png and /dev/null differ
diff --git a/docs/assets/img2img/000030.1592514025.png b/docs/assets/img2img/000030.1592514025.png
deleted file mode 100644
index 0e1641f7eb0..00000000000
Binary files a/docs/assets/img2img/000030.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000030.step-0.png b/docs/assets/img2img/000030.step-0.png
deleted file mode 100644
index 81beb074ec0..00000000000
Binary files a/docs/assets/img2img/000030.step-0.png and /dev/null differ
diff --git a/docs/assets/img2img/000030.steps.gravity.png b/docs/assets/img2img/000030.steps.gravity.png
deleted file mode 100644
index 2bda935a5f3..00000000000
Binary files a/docs/assets/img2img/000030.steps.gravity.png and /dev/null differ
diff --git a/docs/assets/img2img/000032.1592514025.png b/docs/assets/img2img/000032.1592514025.png
deleted file mode 100644
index 0ed2106ec48..00000000000
Binary files a/docs/assets/img2img/000032.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000032.step-0.png b/docs/assets/img2img/000032.step-0.png
deleted file mode 100644
index cc2da68ee43..00000000000
Binary files a/docs/assets/img2img/000032.step-0.png and /dev/null differ
diff --git a/docs/assets/img2img/000032.steps.gravity.png b/docs/assets/img2img/000032.steps.gravity.png
deleted file mode 100644
index 79058c1227a..00000000000
Binary files a/docs/assets/img2img/000032.steps.gravity.png and /dev/null differ
diff --git a/docs/assets/img2img/000034.1592514025.png b/docs/assets/img2img/000034.1592514025.png
deleted file mode 100644
index 43751da5728..00000000000
Binary files a/docs/assets/img2img/000034.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000034.steps.png b/docs/assets/img2img/000034.steps.png
deleted file mode 100644
index 216213162f5..00000000000
Binary files a/docs/assets/img2img/000034.steps.png and /dev/null differ
diff --git a/docs/assets/img2img/000035.1592514025.png b/docs/assets/img2img/000035.1592514025.png
deleted file mode 100644
index d298895080b..00000000000
Binary files a/docs/assets/img2img/000035.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000035.steps.gravity.png b/docs/assets/img2img/000035.steps.gravity.png
deleted file mode 100644
index 122c729e87c..00000000000
Binary files a/docs/assets/img2img/000035.steps.gravity.png and /dev/null differ
diff --git a/docs/assets/img2img/000045.1592514025.png b/docs/assets/img2img/000045.1592514025.png
deleted file mode 100644
index 5e70f1a5bfc..00000000000
Binary files a/docs/assets/img2img/000045.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000045.steps.gravity.png b/docs/assets/img2img/000045.steps.gravity.png
deleted file mode 100644
index 39e2a9b7111..00000000000
Binary files a/docs/assets/img2img/000045.steps.gravity.png and /dev/null differ
diff --git a/docs/assets/img2img/000046.1592514025.png b/docs/assets/img2img/000046.1592514025.png
deleted file mode 100644
index 70d248eb613..00000000000
Binary files a/docs/assets/img2img/000046.1592514025.png and /dev/null differ
diff --git a/docs/assets/img2img/000046.steps.gravity.png b/docs/assets/img2img/000046.steps.gravity.png
deleted file mode 100644
index d801a487015..00000000000
Binary files a/docs/assets/img2img/000046.steps.gravity.png and /dev/null differ
diff --git a/docs/assets/img2img/fire-drawing.png b/docs/assets/img2img/fire-drawing.png
deleted file mode 100644
index 36e2f111fa8..00000000000
Binary files a/docs/assets/img2img/fire-drawing.png and /dev/null differ
diff --git a/docs/assets/inpainting/000019.curly.hair.deselected.png b/docs/assets/inpainting/000019.curly.hair.deselected.png
deleted file mode 100644
index 54f2285550c..00000000000
Binary files a/docs/assets/inpainting/000019.curly.hair.deselected.png and /dev/null differ
diff --git a/docs/assets/inpainting/000019.curly.hair.masked.png b/docs/assets/inpainting/000019.curly.hair.masked.png
deleted file mode 100644
index a221c522f3e..00000000000
Binary files a/docs/assets/inpainting/000019.curly.hair.masked.png and /dev/null differ
diff --git a/docs/assets/inpainting/000019.curly.hair.selected.png b/docs/assets/inpainting/000019.curly.hair.selected.png
deleted file mode 100644
index e25bb4340c5..00000000000
Binary files a/docs/assets/inpainting/000019.curly.hair.selected.png and /dev/null differ
diff --git a/docs/assets/inpainting/000024.801380492.png b/docs/assets/inpainting/000024.801380492.png
deleted file mode 100644
index 9c72eb06b8b..00000000000
Binary files a/docs/assets/inpainting/000024.801380492.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/choose-gpu.png b/docs/assets/installer-walkthrough/choose-gpu.png
deleted file mode 100644
index 3db23ef207b..00000000000
Binary files a/docs/assets/installer-walkthrough/choose-gpu.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/confirm-directory.png b/docs/assets/installer-walkthrough/confirm-directory.png
deleted file mode 100644
index bc3099bbb35..00000000000
Binary files a/docs/assets/installer-walkthrough/confirm-directory.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/downloading-models.png b/docs/assets/installer-walkthrough/downloading-models.png
deleted file mode 100644
index 975a6e39fe9..00000000000
Binary files a/docs/assets/installer-walkthrough/downloading-models.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/installing-models.png b/docs/assets/installer-walkthrough/installing-models.png
deleted file mode 100644
index b2f6190bf5c..00000000000
Binary files a/docs/assets/installer-walkthrough/installing-models.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/settings-form.png b/docs/assets/installer-walkthrough/settings-form.png
deleted file mode 100644
index 84e936ed56b..00000000000
Binary files a/docs/assets/installer-walkthrough/settings-form.png and /dev/null differ
diff --git a/docs/assets/installer-walkthrough/unpacked-zipfile.png b/docs/assets/installer-walkthrough/unpacked-zipfile.png
deleted file mode 100644
index 0e7e5f681f2..00000000000
Binary files a/docs/assets/installer-walkthrough/unpacked-zipfile.png and /dev/null differ
diff --git a/docs/assets/installing-models/model-installer-controlnet.png b/docs/assets/installing-models/model-installer-controlnet.png
deleted file mode 100644
index 09dfdb269fb..00000000000
Binary files a/docs/assets/installing-models/model-installer-controlnet.png and /dev/null differ
diff --git a/docs/assets/installing-models/webui-models-1.png b/docs/assets/installing-models/webui-models-1.png
deleted file mode 100644
index 648791ca270..00000000000
Binary files a/docs/assets/installing-models/webui-models-1.png and /dev/null differ
diff --git a/docs/assets/installing-models/webui-models-2.png b/docs/assets/installing-models/webui-models-2.png
deleted file mode 100644
index e58f5b9752c..00000000000
Binary files a/docs/assets/installing-models/webui-models-2.png and /dev/null differ
diff --git a/docs/assets/installing-models/webui-models-3.png b/docs/assets/installing-models/webui-models-3.png
deleted file mode 100644
index 6797a6b9076..00000000000
Binary files a/docs/assets/installing-models/webui-models-3.png and /dev/null differ
diff --git a/docs/assets/installing-models/webui-models-4.png b/docs/assets/installing-models/webui-models-4.png
deleted file mode 100644
index 75aa7ad7f16..00000000000
Binary files a/docs/assets/installing-models/webui-models-4.png and /dev/null differ
diff --git a/docs/assets/invoke-control-panel-1.png b/docs/assets/invoke-control-panel-1.png
deleted file mode 100644
index 09046676007..00000000000
Binary files a/docs/assets/invoke-control-panel-1.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-2.png b/docs/assets/invoke-web-server-2.png
deleted file mode 100644
index 361c113159c..00000000000
Binary files a/docs/assets/invoke-web-server-2.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-3.png b/docs/assets/invoke-web-server-3.png
deleted file mode 100644
index 7d392cfecca..00000000000
Binary files a/docs/assets/invoke-web-server-3.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-4.png b/docs/assets/invoke-web-server-4.png
deleted file mode 100644
index 2690356b8ac..00000000000
Binary files a/docs/assets/invoke-web-server-4.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-5.png b/docs/assets/invoke-web-server-5.png
deleted file mode 100644
index 1923e641294..00000000000
Binary files a/docs/assets/invoke-web-server-5.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-6.png b/docs/assets/invoke-web-server-6.png
deleted file mode 100644
index 0e8f703cb5a..00000000000
Binary files a/docs/assets/invoke-web-server-6.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-7.png b/docs/assets/invoke-web-server-7.png
deleted file mode 100644
index 19769dea93e..00000000000
Binary files a/docs/assets/invoke-web-server-7.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-8.png b/docs/assets/invoke-web-server-8.png
deleted file mode 100644
index dc708b11177..00000000000
Binary files a/docs/assets/invoke-web-server-8.png and /dev/null differ
diff --git a/docs/assets/invoke-web-server-9.png b/docs/assets/invoke-web-server-9.png
deleted file mode 100644
index 9d0d31bec0c..00000000000
Binary files a/docs/assets/invoke-web-server-9.png and /dev/null differ
diff --git a/docs/assets/invoke_web_dark.png b/docs/assets/invoke_web_dark.png
deleted file mode 100644
index 9141ab40f37..00000000000
Binary files a/docs/assets/invoke_web_dark.png and /dev/null differ
diff --git a/docs/assets/invoke_web_light.png b/docs/assets/invoke_web_light.png
deleted file mode 100644
index 98311ccafd6..00000000000
Binary files a/docs/assets/invoke_web_light.png and /dev/null differ
diff --git a/docs/assets/invoke_web_server.png b/docs/assets/invoke_web_server.png
deleted file mode 100644
index cdb34a0835b..00000000000
Binary files a/docs/assets/invoke_web_server.png and /dev/null differ
diff --git a/docs/assets/join-us-on-discord-image.png b/docs/assets/join-us-on-discord-image.png
deleted file mode 100644
index 53e4ee0fe05..00000000000
Binary files a/docs/assets/join-us-on-discord-image.png and /dev/null differ
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
deleted file mode 100644
index b6eb33a6db5..00000000000
Binary files a/docs/assets/logo.png and /dev/null differ
diff --git a/docs/assets/lora-example-0.png b/docs/assets/lora-example-0.png
deleted file mode 100644
index f98fa53ca41..00000000000
Binary files a/docs/assets/lora-example-0.png and /dev/null differ
diff --git a/docs/assets/lora-example-1.png b/docs/assets/lora-example-1.png
deleted file mode 100644
index 29ea46e970b..00000000000
Binary files a/docs/assets/lora-example-1.png and /dev/null differ
diff --git a/docs/assets/lora-example-2.png b/docs/assets/lora-example-2.png
deleted file mode 100644
index 40eecdce849..00000000000
Binary files a/docs/assets/lora-example-2.png and /dev/null differ
diff --git a/docs/assets/lora-example-3.png b/docs/assets/lora-example-3.png
deleted file mode 100644
index be4c505d439..00000000000
Binary files a/docs/assets/lora-example-3.png and /dev/null differ
diff --git a/docs/assets/negative_prompt_walkthru/step1.png b/docs/assets/negative_prompt_walkthru/step1.png
deleted file mode 100644
index 6f94d7d035a..00000000000
Binary files a/docs/assets/negative_prompt_walkthru/step1.png and /dev/null differ
diff --git a/docs/assets/negative_prompt_walkthru/step2.png b/docs/assets/negative_prompt_walkthru/step2.png
deleted file mode 100644
index 0ff90eca3c1..00000000000
Binary files a/docs/assets/negative_prompt_walkthru/step2.png and /dev/null differ
diff --git a/docs/assets/negative_prompt_walkthru/step3.png b/docs/assets/negative_prompt_walkthru/step3.png
deleted file mode 100644
index f6676de3868..00000000000
Binary files a/docs/assets/negative_prompt_walkthru/step3.png and /dev/null differ
diff --git a/docs/assets/negative_prompt_walkthru/step4.png b/docs/assets/negative_prompt_walkthru/step4.png
deleted file mode 100644
index 2e73532629d..00000000000
Binary files a/docs/assets/negative_prompt_walkthru/step4.png and /dev/null differ
diff --git a/docs/assets/outpainting/curly-outcrop-2.png b/docs/assets/outpainting/curly-outcrop-2.png
deleted file mode 100644
index 595f011f27e..00000000000
Binary files a/docs/assets/outpainting/curly-outcrop-2.png and /dev/null differ
diff --git a/docs/assets/outpainting/curly-outcrop.png b/docs/assets/outpainting/curly-outcrop.png
deleted file mode 100644
index ae8d8dacd3d..00000000000
Binary files a/docs/assets/outpainting/curly-outcrop.png and /dev/null differ
diff --git a/docs/assets/outpainting/curly-outpaint.png b/docs/assets/outpainting/curly-outpaint.png
deleted file mode 100644
index 9f4a2ee431e..00000000000
Binary files a/docs/assets/outpainting/curly-outpaint.png and /dev/null differ
diff --git a/docs/assets/outpainting/curly.png b/docs/assets/outpainting/curly.png
deleted file mode 100644
index d9a4cb257ee..00000000000
Binary files a/docs/assets/outpainting/curly.png and /dev/null differ
diff --git a/docs/assets/prompt-blending/blue-sphere-0.25-red-cube-0.75-hybrid.png b/docs/assets/prompt-blending/blue-sphere-0.25-red-cube-0.75-hybrid.png
deleted file mode 100644
index 43d780a3439..00000000000
Binary files a/docs/assets/prompt-blending/blue-sphere-0.25-red-cube-0.75-hybrid.png and /dev/null differ
diff --git a/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5-hybrid.png b/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5-hybrid.png
deleted file mode 100644
index c131ae03f4d..00000000000
Binary files a/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5-hybrid.png and /dev/null differ
diff --git a/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5.png b/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5.png
deleted file mode 100644
index d0cb8e389ee..00000000000
Binary files a/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5.png and /dev/null differ
diff --git a/docs/assets/prompt-blending/blue-sphere-0.75-red-cube-0.25-hybrid.png b/docs/assets/prompt-blending/blue-sphere-0.75-red-cube-0.25-hybrid.png
deleted file mode 100644
index b7ddf6658bf..00000000000
Binary files a/docs/assets/prompt-blending/blue-sphere-0.75-red-cube-0.25-hybrid.png and /dev/null differ
diff --git a/docs/assets/prompt-blending/blue-sphere-red-cube-hybrid.png b/docs/assets/prompt-blending/blue-sphere-red-cube-hybrid.png
deleted file mode 100644
index 3ec14564e61..00000000000
Binary files a/docs/assets/prompt-blending/blue-sphere-red-cube-hybrid.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots--1.png b/docs/assets/prompt_syntax/apricots--1.png
deleted file mode 100644
index 0f0c17f08b0..00000000000
Binary files a/docs/assets/prompt_syntax/apricots--1.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots--2.png b/docs/assets/prompt_syntax/apricots--2.png
deleted file mode 100644
index 5c519b09aed..00000000000
Binary files a/docs/assets/prompt_syntax/apricots--2.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots--3.png b/docs/assets/prompt_syntax/apricots--3.png
deleted file mode 100644
index c98ffd8b071..00000000000
Binary files a/docs/assets/prompt_syntax/apricots--3.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-0.png b/docs/assets/prompt_syntax/apricots-0.png
deleted file mode 100644
index f8ead74db2f..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-0.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-1.png b/docs/assets/prompt_syntax/apricots-1.png
deleted file mode 100644
index 75ff7a24a3d..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-1.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-2.png b/docs/assets/prompt_syntax/apricots-2.png
deleted file mode 100644
index e24ced76375..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-2.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-3.png b/docs/assets/prompt_syntax/apricots-3.png
deleted file mode 100644
index d6edf5073c9..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-3.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-4.png b/docs/assets/prompt_syntax/apricots-4.png
deleted file mode 100644
index 291c5a1b03b..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-4.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/apricots-5.png b/docs/assets/prompt_syntax/apricots-5.png
deleted file mode 100644
index c71b8578375..00000000000
Binary files a/docs/assets/prompt_syntax/apricots-5.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain-man.png b/docs/assets/prompt_syntax/mountain-man.png
deleted file mode 100644
index 4bfa5817b8c..00000000000
Binary files a/docs/assets/prompt_syntax/mountain-man.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain-man1.png b/docs/assets/prompt_syntax/mountain-man1.png
deleted file mode 100644
index 5ed98162d35..00000000000
Binary files a/docs/assets/prompt_syntax/mountain-man1.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain-man2.png b/docs/assets/prompt_syntax/mountain-man2.png
deleted file mode 100644
index d4466514ded..00000000000
Binary files a/docs/assets/prompt_syntax/mountain-man2.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain-man3.png b/docs/assets/prompt_syntax/mountain-man3.png
deleted file mode 100644
index 3196c5ca96f..00000000000
Binary files a/docs/assets/prompt_syntax/mountain-man3.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain-man4.png b/docs/assets/prompt_syntax/mountain-man4.png
deleted file mode 100644
index 69522dba232..00000000000
Binary files a/docs/assets/prompt_syntax/mountain-man4.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain1-man.png b/docs/assets/prompt_syntax/mountain1-man.png
deleted file mode 100644
index b5952d02f93..00000000000
Binary files a/docs/assets/prompt_syntax/mountain1-man.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain2-man.png b/docs/assets/prompt_syntax/mountain2-man.png
deleted file mode 100644
index 8ab55ff2a7e..00000000000
Binary files a/docs/assets/prompt_syntax/mountain2-man.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/mountain3-man.png b/docs/assets/prompt_syntax/mountain3-man.png
deleted file mode 100644
index c1024b0a635..00000000000
Binary files a/docs/assets/prompt_syntax/mountain3-man.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png b/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png
deleted file mode 100644
index 8d5336da3d6..00000000000
Binary files a/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png and /dev/null differ
diff --git a/docs/assets/prompt_syntax/sdxl-prompt.png b/docs/assets/prompt_syntax/sdxl-prompt.png
deleted file mode 100644
index b85464c5ad8..00000000000
Binary files a/docs/assets/prompt_syntax/sdxl-prompt.png and /dev/null differ
diff --git a/docs/assets/sdxl-graphs/sdxl-base-example1.json b/docs/assets/sdxl-graphs/sdxl-base-example1.json
deleted file mode 100644
index af162ba3e91..00000000000
--- a/docs/assets/sdxl-graphs/sdxl-base-example1.json
+++ /dev/null
@@ -1 +0,0 @@
-{"nodes":[{"width":387,"height":565,"dragHandle":".node-drag-handle","id":"800b3166-5044-4987-ba13-f839963fec96","type":"invocation","position":{"x":-169.10829044927982,"y":-272.36451106154334},"data":{"id":"800b3166-5044-4987-ba13-f839963fec96","type":"sdxl_compel_prompt","inputs":{"prompt":{"id":"8e76febd-6692-4a40-8bba-cbac54bccf1a","name":"prompt","type":"string","value":"bluebird in a sakura tree"},"style":{"id":"cd0e7434-04d8-4e36-b08b-3342d6ab4caa","name":"style","type":"string","value":""},"original_width":{"id":"cd259354-c92c-4e1c-815a-205fb38f8fec","name":"original_width","type":"integer","value":1024},"original_height":{"id":"b5cebf0e-97ce-4cd3-92be-fad37472daf2","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"e6763334-676e-42fd-9fbf-0537ff002013","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"00042856-acd5-4f99-a0e1-2d0b567ebf0f","name":"crop_left","type":"integer","value":0},"target_width":{"id":"800e17aa-1c15-4cdc-bceb-1f3f3ef5d244","name":"target_width","type":"integer","value":1024},"target_height":{"id":"91079f90-886b-440f-8236-236a239af747","name":"target_height","type":"integer","value":1024},"clip":{"id":"b32c84f8-b39e-4c23-8a77-fc62944ef942","name":"clip","type":"clip"},"clip2":{"id":"80db777a-3e3d-4132-ba0b-75a0b2135809","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"ea368e7a-bc93-4492-8fc6-e1d2fe2f5e2a","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":-169.10829044927982,"y":-272.36451106154334},"dragging":false},{"width":387,"height":565,"dragHandle":".node-drag-handle","id":"f3b94b55-bd55-4244-87b2-bf92e4ebbd2a","type":"invocation","position":{"x":-173.7213091998297,"y":346.42482955878256},"data":{"id":"f3b94b55-bd55-4244-87b2-bf92e4ebbd2a","type":"sdxl_compel_prompt","inputs":{"prompt":{"id":"d718b007-51f9-4385-bbf9-fa99756d5a4c","name":"prompt","type":"string","value":""},"style":{"id":"6476aafe-9e17-48b2-b66c-fbb8ff555926","name":"style","type":"string","value":""},"original_width":{"id":"7f4cf0bd-d5ab-460f-9484-375160f87620","name":"original_width","type":"integer","value":1024},"original_height":{"id":"8c1695a7-2b1b-44ba-8644-da207c551366","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"5e7e7434-e966-4592-a422-924784192719","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"cbbbc129-c481-4a28-a944-ad894dcec8b1","name":"crop_left","type":"integer","value":0},"target_width":{"id":"18bb5fb9-d488-41c3-9d8b-1b0e5b163676","name":"target_width","type":"integer","value":1024},"target_height":{"id":"44cbc645-b582-48c7-9e37-8aabd2615355","name":"target_height","type":"integer","value":1024},"clip":{"id":"65f8cad6-9aaf-4d21-91af-e9aeb9657bf2","name":"clip","type":"clip"},"clip2":{"id":"db5cdf20-9968-4932-b7de-adbc2460ca31","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"4f0236e9-0bf1-4410-867d-7241f1fe3e4c","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":-173.7213091998297,"y":346.42482955878256},"dragging":false},{"width":334,"height":269,"dragHandle":".node-drag-handle","id":"e29a9d29-2f63-487b-89b8-4184b1871c8f","type":"invocation","position":{"x":-652.2340297939481,"y":338.43526096363286},"data":{"id":"e29a9d29-2f63-487b-89b8-4184b1871c8f","type":"sdxl_model_loader","inputs":{"model":{"id":"4b19dda6-31fe-4317-a741-a19f8d8ec18f","name":"model","type":"model","value":{"model_name":"stable-diffusion-xl-base-0.9","base_model":"sdxl"}}},"outputs":{"unet":{"id":"d816da3a-7cb9-4d0d-8b1a-cea4e596c11e","name":"unet","type":"unet"},"clip":{"id":"35af0240-3014-4a2e-b3f2-f79ddffaca29","name":"clip","type":"clip"},"clip2":{"id":"25360ee2-ce84-4d04-95d6-a6e63db7e87c","name":"clip2","type":"clip"},"vae":{"id":"e8f15338-cbbe-45a1-8248-059b3601b058","name":"vae","type":"vae"}}},"selected":false,"positionAbsolute":{"x":-652.2340297939481,"y":338.43526096363286},"dragging":false},{"width":384,"height":519,"dragHandle":".node-drag-handle","id":"41f3e341-a2ae-4741-b0cd-dea20abde273","type":"invocation","position":{"x":796.6378578223604,"y":-192.53063049405188},"data":{"id":"41f3e341-a2ae-4741-b0cd-dea20abde273","type":"t2l_sdxl","inputs":{"positive_conditioning":{"id":"2b679eb8-3d15-4b4c-9a68-334bf43ea3d3","name":"positive_conditioning","type":"conditioning"},"negative_conditioning":{"id":"d3cfcf4b-8b31-4f88-98ed-3486dd60fa6f","name":"negative_conditioning","type":"conditioning"},"noise":{"id":"ed624337-7042-4a28-adf1-c8a6def87b4c","name":"noise","type":"latents"},"steps":{"id":"92f299da-c6ec-45fc-ad3d-d6af0f9862e6","name":"steps","type":"integer","value":10},"cfg_scale":{"id":"b129fa4e-c772-48cd-9dad-9b7223ddfcf5","name":"cfg_scale","type":"float","value":7.5},"scheduler":{"id":"d5e86735-8301-42f4-8fc4-276120e444cd","name":"scheduler","type":"enum","value":"euler"},"unet":{"id":"9affc51f-37a9-4468-90c9-bec97efda3f4","name":"unet","type":"unet"},"denoising_end":{"id":"91f41083-910e-4955-b3ae-d93ad3d1d801","name":"denoising_end","type":"float","value":1}},"outputs":{"latents":{"id":"f6b119cc-cf8d-46c0-8f43-bf712c509938","name":"latents","type":"latents"},"width":{"id":"cfcced65-62b2-40c7-b2bf-6534220eeaa1","name":"width","type":"integer"},"height":{"id":"819fbf37-8db1-4f5c-ad6b-a5cdb71e8c9a","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":796.6378578223604,"y":-192.53063049405188},"dragging":false},{"width":333,"height":344,"dragHandle":".node-drag-handle","id":"357773ee-386a-423a-a712-a23812554cbb","type":"invocation","position":{"x":401.8907147451813,"y":576.7882372082732},"data":{"id":"357773ee-386a-423a-a712-a23812554cbb","type":"noise","inputs":{"seed":{"id":"9eaa6f6f-bde4-42b6-945a-a09b6cc46531","name":"seed","type":"integer","value":10},"width":{"id":"241032d2-6d05-47c1-bf9e-1a13ef95956b","name":"width","type":"integer","value":1024},"height":{"id":"71eda9a5-4460-4e7e-ba7a-2d99fa47ddf5","name":"height","type":"integer","value":1024},"use_cpu":{"id":"1dd54a91-deaa-4b83-be89-230c517d7b6b","name":"use_cpu","type":"boolean","value":true}},"outputs":{"noise":{"id":"321604ef-b33b-4529-91af-0d8d03d23f2f","name":"noise","type":"latents"},"width":{"id":"fc1f68f3-720f-4dbe-a9c9-b8a08cca768a","name":"width","type":"integer"},"height":{"id":"61022078-48f5-4faf-923e-9cddb508be30","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":401.8907147451813,"y":576.7882372082732},"dragging":false},{"width":250,"height":323,"dragHandle":".node-drag-handle","id":"3e712379-88e7-465d-94d1-e06160b7cfae","type":"invocation","position":{"x":1237.2519670580534,"y":257.58346014925485},"data":{"id":"3e712379-88e7-465d-94d1-e06160b7cfae","type":"l2i","inputs":{"latents":{"id":"453d1aac-7bfe-480b-bc6f-2c3b110cd9b8","name":"latents","type":"latents"},"vae":{"id":"c7c38097-3620-4eb8-aec5-4ce1b043bb83","name":"vae","type":"vae"},"tiled":{"id":"2cab559e-fbaa-4cbf-ae9b-e5e43df38369","name":"tiled","type":"boolean","value":false},"fp32":{"id":"daf6c8df-325a-4cf6-9e86-d8962d838f17","name":"fp32","type":"boolean","value":true}},"outputs":{"image":{"id":"6ead6575-d4ad-4dd2-b6e0-5b70f9cce726","name":"image","type":"image"},"width":{"id":"734a4436-2c6f-4c57-b1cb-25a01548031d","name":"width","type":"integer"},"height":{"id":"7a03a4af-ab06-43f3-bb4d-1789b6f2c5e9","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":1237.2519670580534,"y":257.58346014925485},"dragging":false},{"width":320,"height":187,"dragHandle":".node-drag-handle","id":"2423e526-ba3c-448e-8240-7442508d4609","type":"invocation","position":{"x":-33.59691285742994,"y":945.2263448868741},"data":{"id":"2423e526-ba3c-448e-8240-7442508d4609","type":"rand_int","inputs":{"low":{"id":"70ffaec8-3064-45dc-8b5c-6b4f43cc3b62","name":"low","type":"integer","value":0},"high":{"id":"82e10321-ce31-4e86-9e21-077c16a18e3d","name":"high","type":"integer","value":2147483647}},"outputs":{"a":{"id":"bba34654-8a92-40db-9810-7f75ddad8ae9","name":"a","type":"integer"}}},"selected":true,"positionAbsolute":{"x":-33.59691285742994,"y":945.2263448868741},"dragging":false}],"edges":[{"source":"41f3e341-a2ae-4741-b0cd-dea20abde273","sourceHandle":"latents","target":"3e712379-88e7-465d-94d1-e06160b7cfae","targetHandle":"latents","id":"reactflow__edge-41f3e341-a2ae-4741-b0cd-dea20abde273latents-3e712379-88e7-465d-94d1-e06160b7cfaelatents"},{"source":"800b3166-5044-4987-ba13-f839963fec96","sourceHandle":"conditioning","target":"41f3e341-a2ae-4741-b0cd-dea20abde273","targetHandle":"positive_conditioning","id":"reactflow__edge-800b3166-5044-4987-ba13-f839963fec96conditioning-41f3e341-a2ae-4741-b0cd-dea20abde273positive_conditioning"},{"source":"f3b94b55-bd55-4244-87b2-bf92e4ebbd2a","sourceHandle":"conditioning","target":"41f3e341-a2ae-4741-b0cd-dea20abde273","targetHandle":"negative_conditioning","id":"reactflow__edge-f3b94b55-bd55-4244-87b2-bf92e4ebbd2aconditioning-41f3e341-a2ae-4741-b0cd-dea20abde273negative_conditioning"},{"source":"357773ee-386a-423a-a712-a23812554cbb","sourceHandle":"noise","target":"41f3e341-a2ae-4741-b0cd-dea20abde273","targetHandle":"noise","id":"reactflow__edge-357773ee-386a-423a-a712-a23812554cbbnoise-41f3e341-a2ae-4741-b0cd-dea20abde273noise"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"vae","target":"3e712379-88e7-465d-94d1-e06160b7cfae","targetHandle":"vae","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8fvae-3e712379-88e7-465d-94d1-e06160b7cfaevae"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"clip","target":"800b3166-5044-4987-ba13-f839963fec96","targetHandle":"clip","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8fclip-800b3166-5044-4987-ba13-f839963fec96clip"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"clip2","target":"800b3166-5044-4987-ba13-f839963fec96","targetHandle":"clip2","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8fclip2-800b3166-5044-4987-ba13-f839963fec96clip2"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"clip","target":"f3b94b55-bd55-4244-87b2-bf92e4ebbd2a","targetHandle":"clip","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8fclip-f3b94b55-bd55-4244-87b2-bf92e4ebbd2aclip"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"clip2","target":"f3b94b55-bd55-4244-87b2-bf92e4ebbd2a","targetHandle":"clip2","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8fclip2-f3b94b55-bd55-4244-87b2-bf92e4ebbd2aclip2"},{"source":"e29a9d29-2f63-487b-89b8-4184b1871c8f","sourceHandle":"unet","target":"41f3e341-a2ae-4741-b0cd-dea20abde273","targetHandle":"unet","id":"reactflow__edge-e29a9d29-2f63-487b-89b8-4184b1871c8funet-41f3e341-a2ae-4741-b0cd-dea20abde273unet"},{"source":"2423e526-ba3c-448e-8240-7442508d4609","sourceHandle":"a","target":"357773ee-386a-423a-a712-a23812554cbb","targetHandle":"seed","id":"reactflow__edge-2423e526-ba3c-448e-8240-7442508d4609a-357773ee-386a-423a-a712-a23812554cbbseed"}],"viewport":{"x":473.83885376565604,"y":374.3473116493717,"zoom":0.5904963307147653}}
\ No newline at end of file
diff --git a/docs/assets/sdxl-graphs/sdxl-base-refine-example1.json b/docs/assets/sdxl-graphs/sdxl-base-refine-example1.json
deleted file mode 100644
index 6935c15fd0d..00000000000
--- a/docs/assets/sdxl-graphs/sdxl-base-refine-example1.json
+++ /dev/null
@@ -1 +0,0 @@
-{"nodes":[{"width":309,"height":138,"dragHandle":".node-drag-handle","id":"2daddd1d-a468-4841-9ff4-57befb3518a1","type":"invocation","position":{"x":-352.03597532489465,"y":-853.3432520441677},"data":{"id":"2daddd1d-a468-4841-9ff4-57befb3518a1","type":"param_string","inputs":{"text":{"id":"aa4e1128-b6e0-4d8b-831b-723e9879b307","name":"text","type":"string","value":"bluebird in a sakura tree"}},"outputs":{"text":{"id":"8605a8bb-ea46-402d-a0cf-658ad66aa589","name":"text","type":"string"}}},"selected":false,"positionAbsolute":{"x":-352.03597532489465,"y":-853.3432520441677},"dragging":false},{"width":309,"height":138,"dragHandle":".node-drag-handle","id":"0cee1a14-34fa-4b5e-b361-9ebfaba62844","type":"invocation","position":{"x":-350.69241780355867,"y":-671.6645885606935},"data":{"id":"0cee1a14-34fa-4b5e-b361-9ebfaba62844","type":"param_string","inputs":{"text":{"id":"55a39e14-81ad-4112-94b3-62faba370317","name":"text","type":"string","value":"classical chinese painting"}},"outputs":{"text":{"id":"60a32b20-0e81-4936-bf68-38f65c13cfab","name":"text","type":"string"}}},"selected":false,"positionAbsolute":{"x":-350.69241780355867,"y":-671.6645885606935},"dragging":false},{"width":309,"height":138,"dragHandle":".node-drag-handle","id":"3898e2dd-0efa-44cf-8d6c-0d23ef184419","type":"invocation","position":{"x":-353.4581776953401,"y":-483.26349757856553},"data":{"id":"3898e2dd-0efa-44cf-8d6c-0d23ef184419","type":"param_string","inputs":{"text":{"id":"79c9244d-b40b-45f7-8351-a0e3aad9c203","name":"text","type":"string","value":""}},"outputs":{"text":{"id":"c49253f2-57d2-4ea8-920e-2d0107834c3a","name":"text","type":"string"}}},"selected":false,"positionAbsolute":{"x":-353.4581776953401,"y":-483.26349757856553},"dragging":false},{"width":309,"height":138,"dragHandle":".node-drag-handle","id":"96935e84-c864-48a4-b6c0-c940c71c43ae","type":"invocation","position":{"x":-355.15166832006526,"y":-312.2209444813352},"data":{"id":"96935e84-c864-48a4-b6c0-c940c71c43ae","type":"param_string","inputs":{"text":{"id":"a57a792a-3621-489e-bae4-0a413510caef","name":"text","type":"string","value":""}},"outputs":{"text":{"id":"40c229fc-b044-40cd-8b31-31f0562ca7ae","name":"text","type":"string"}}},"selected":false,"positionAbsolute":{"x":-355.15166832006526,"y":-312.2209444813352},"dragging":false},{"width":334,"height":269,"dragHandle":".node-drag-handle","id":"f3cd6592-f3a4-4d4e-a833-deb36314716f","type":"invocation","position":{"x":-405.6072492884527,"y":-90.55508106907858},"data":{"id":"f3cd6592-f3a4-4d4e-a833-deb36314716f","type":"sdxl_model_loader","inputs":{"model":{"id":"e54a5f62-7fb2-46ab-bec2-e1d1de26245b","name":"model","type":"model","value":{"model_name":"stable-diffusion-xl-base-0.9","base_model":"sdxl"}}},"outputs":{"unet":{"id":"59d159bd-42e0-40ee-8c5e-0021158d2f2e","name":"unet","type":"unet"},"clip":{"id":"232c0412-142b-4f61-b3f2-673e8375452c","name":"clip","type":"clip"},"clip2":{"id":"bfaaf2ed-d5fb-419f-8f4d-1c47375b23d9","name":"clip2","type":"clip"},"vae":{"id":"b7699621-12fd-4364-b68d-27fb322fcc2b","name":"vae","type":"vae"}}},"selected":false,"positionAbsolute":{"x":-405.6072492884527,"y":-90.55508106907858},"dragging":false},{"width":387,"height":565,"dragHandle":".node-drag-handle","id":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","type":"invocation","position":{"x":81.8860645633394,"y":-680.6110236371882},"data":{"id":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","type":"sdxl_compel_prompt","inputs":{"prompt":{"id":"9df5bf37-ff52-4deb-b8b2-59670578bd74","name":"prompt","type":"string","value":""},"style":{"id":"66b685eb-2c97-4dee-be34-fd10bda699fb","name":"style","type":"string","value":""},"original_width":{"id":"0dada1d1-305f-4b46-b342-e72150cf135d","name":"original_width","type":"integer","value":1024},"original_height":{"id":"fcbc471b-982e-4fed-9882-981d5490ab24","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"7b891ead-37b4-4fcf-a642-1b27589ef8b8","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"3d7d7f57-62af-42a6-ac55-80a5dcc6a6a1","name":"crop_left","type":"integer","value":0},"target_width":{"id":"e51a406e-185b-4447-82a9-752118d86ded","name":"target_width","type":"integer","value":1024},"target_height":{"id":"86aac99d-8671-4cdf-9f4b-fbd9261a1a43","name":"target_height","type":"integer","value":1024},"clip":{"id":"a6c1cdc5-ea91-4aaa-8b27-d5b0ea149d2c","name":"clip","type":"clip"},"clip2":{"id":"4af4c3c4-d23a-4940-8597-e1181b54e2b1","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"f8a844c4-8395-4645-87b8-7b8f02057302","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":81.8860645633394,"y":-680.6110236371882},"dragging":false},{"width":387,"height":565,"dragHandle":".node-drag-handle","id":"addd5054-97d8-466c-ab3c-41f5e039af02","type":"invocation","position":{"x":82.9601624843504,"y":-78.81640327238692},"data":{"id":"addd5054-97d8-466c-ab3c-41f5e039af02","type":"sdxl_compel_prompt","inputs":{"prompt":{"id":"d6eee212-c125-4c35-8028-a8c980496ee2","name":"prompt","type":"string","value":""},"style":{"id":"88c7d42e-5868-4e75-ab37-0b05a4cfc84d","name":"style","type":"string","value":""},"original_width":{"id":"21897c23-5bb2-46c0-b0e3-9e481c6828ab","name":"original_width","type":"integer","value":1024},"original_height":{"id":"70b41031-48b6-4c3b-9683-8332b344cc88","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"a1e7aad9-1088-47c9-9956-eed7ae857a15","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"9a237728-23ea-42fd-98c3-1b3ee571d8de","name":"crop_left","type":"integer","value":0},"target_width":{"id":"48ee0274-c952-4a8f-8b11-17c6cbad3546","name":"target_width","type":"integer","value":1024},"target_height":{"id":"4c75e60f-fd59-46f0-95dc-825a43fb329f","name":"target_height","type":"integer","value":1024},"clip":{"id":"1cb885c0-efc8-451c-9208-765f5c436950","name":"clip","type":"clip"},"clip2":{"id":"0b98f464-3f10-4dc3-aa14-22e25ac1a301","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"6cdd9112-92ec-4b34-9c10-c185f66d3d2a","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":82.9601624843504,"y":-78.81640327238692},"dragging":false},{"width":394,"height":425,"dragHandle":".node-drag-handle","id":"0af8fc93-be59-4002-9956-c700fb778d4a","type":"invocation","position":{"x":1100.1437566814066,"y":-851.6600425944865},"data":{"id":"0af8fc93-be59-4002-9956-c700fb778d4a","type":"sdxl_refiner_compel_prompt","inputs":{"style":{"id":"fda33c4f-84ef-4d82-bff1-b1b4b7c0f272","name":"style","type":"string","value":""},"original_width":{"id":"daf3c09d-d0bf-441f-ab05-5e8c16c8e136","name":"original_width","type":"integer","value":1024},"original_height":{"id":"d20c06c8-db5b-48db-92d9-79ee8ccd44f8","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"29ad28e3-a497-4949-8ee5-ddb9645ca38f","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"c4bdccc3-2f11-420a-abbe-2b4bd66a11c6","name":"crop_left","type":"integer","value":0},"aesthetic_score":{"id":"342061a0-eb9b-43fc-ac41-707b10355256","name":"aesthetic_score","type":"float","value":6},"clip2":{"id":"9fc7860d-aa7f-47fe-b8c9-1add0aee25bc","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"87cc45e3-839e-4a20-b93f-7892dbd53d75","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":1100.1437566814066,"y":-851.6600425944865},"dragging":false},{"width":334,"height":236,"dragHandle":".node-drag-handle","id":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","type":"invocation","position":{"x":542.9067581903546,"y":-677.6764639738383},"data":{"id":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","type":"sdxl_refiner_model_loader","inputs":{"model":{"id":"75b59470-6fac-48c6-a2c8-957ecb24abc9","name":"model","type":"model","value":{"model_name":"stable-diffusion-xl-refiner-0.9","base_model":"sdxl-refiner"}}},"outputs":{"unet":{"id":"8c78fcbd-a23d-4434-944c-5d1798156ae4","name":"unet","type":"unet"},"clip2":{"id":"9456e151-1b09-4853-9855-ad6c9f659720","name":"clip2","type":"clip"},"vae":{"id":"a86160bc-037c-4516-8e74-c6298e4d1f15","name":"vae","type":"vae"}}},"selected":true,"positionAbsolute":{"x":542.9067581903546,"y":-677.6764639738383},"dragging":false},{"width":394,"height":425,"dragHandle":".node-drag-handle","id":"c5b420c9-82cd-4863-b416-a620009f2d36","type":"invocation","position":{"x":1090.7741649651036,"y":-368.4448651589644},"data":{"id":"c5b420c9-82cd-4863-b416-a620009f2d36","type":"sdxl_refiner_compel_prompt","inputs":{"style":{"id":"90b996b2-e9f2-4b9b-acf9-d19804fe0228","name":"style","type":"string","value":""},"original_width":{"id":"7d511718-55c7-4c78-9bd5-89c6e293c1c2","name":"original_width","type":"integer","value":1024},"original_height":{"id":"1ec9ea13-ea31-4abd-af49-32d288313291","name":"original_height","type":"integer","value":1024},"crop_top":{"id":"ea1db4fd-521c-4439-9f57-d7d4f16175e7","name":"crop_top","type":"integer","value":0},"crop_left":{"id":"6e7af04e-0281-4f68-a393-1ec5b3af54c1","name":"crop_left","type":"integer","value":0},"aesthetic_score":{"id":"6c270052-bea0-4b08-a6b3-6601457f47bc","name":"aesthetic_score","type":"float","value":6},"clip2":{"id":"813f23b9-533e-4f65-82b3-c5d6cda09a22","name":"clip2","type":"clip"}},"outputs":{"conditioning":{"id":"46e6250e-daea-4d8d-ba32-d3405670cdae","name":"conditioning","type":"conditioning"}}},"selected":false,"positionAbsolute":{"x":1090.7741649651036,"y":-368.4448651589644},"dragging":false},{"width":333,"height":344,"dragHandle":".node-drag-handle","id":"fface4ab-25e3-4714-aca9-bb6c67842cd5","type":"invocation","position":{"x":572.6787481456306,"y":464.11370246551087},"data":{"id":"fface4ab-25e3-4714-aca9-bb6c67842cd5","type":"noise","inputs":{"seed":{"id":"c820a274-fb57-408d-b2b6-a1b1e2db4c39","name":"seed","type":"integer","value":0},"width":{"id":"533435f9-a82d-4841-8a17-33e6c766cd03","name":"width","type":"integer","value":1024},"height":{"id":"8d350b62-ed7a-484c-9cf2-ffe54a96465d","name":"height","type":"integer","value":1024},"use_cpu":{"id":"2cb494a6-5550-43da-802b-4809314615d4","name":"use_cpu","type":"boolean","value":true}},"outputs":{"noise":{"id":"e5a7a90f-656b-474a-b4cb-475d904e82c7","name":"noise","type":"latents"},"width":{"id":"e5b9de92-07f1-4dc5-95de-3096eca8db30","name":"width","type":"integer"},"height":{"id":"8489a4ce-41e9-4d69-bd8d-f0b862d84a3e","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":572.6787481456306,"y":464.11370246551087},"dragging":false},{"width":384,"height":519,"dragHandle":".node-drag-handle","id":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","type":"invocation","position":{"x":1596.8574685369997,"y":277.26040215989804},"data":{"id":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","type":"t2l_sdxl","inputs":{"positive_conditioning":{"id":"77c4bda4-2d88-4d7c-bfc4-a0843a850109","name":"positive_conditioning","type":"conditioning"},"negative_conditioning":{"id":"4aad4196-2211-401a-82cf-0a66dc8b8374","name":"negative_conditioning","type":"conditioning"},"noise":{"id":"854bd0c1-a480-4c3a-a57a-0bce2d862c89","name":"noise","type":"latents"},"steps":{"id":"828a4cb3-746d-4f21-ab48-ff25b4f4b9f7","name":"steps","type":"integer","value":20},"cfg_scale":{"id":"77ec37ee-775b-49e9-8c6b-96dd81a8b561","name":"cfg_scale","type":"float","value":7.5},"scheduler":{"id":"12cd8ca4-b973-4875-a3e0-e17898ec514a","name":"scheduler","type":"enum","value":"euler"},"unet":{"id":"3465ea9c-f80b-46da-aa2d-6c550dcf4312","name":"unet","type":"unet"},"denoising_end":{"id":"717c3255-3aff-4eb5-9935-2f9b6794571e","name":"denoising_end","type":"float","value":1}},"outputs":{"latents":{"id":"eab9d5f1-aa6e-4ef5-87e6-1a5240e6638a","name":"latents","type":"latents"},"width":{"id":"957af5db-5c7d-4838-ae23-b4da184e4d92","name":"width","type":"integer"},"height":{"id":"337f2d41-78e7-4692-9f9e-7db102f97f8e","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":1596.8574685369997,"y":277.26040215989804},"dragging":false},{"width":391,"height":610,"dragHandle":".node-drag-handle","id":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","type":"invocation","position":{"x":2044.2621231418175,"y":-374.36499568795915},"data":{"id":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","type":"l2l_sdxl","inputs":{"positive_conditioning":{"id":"561c26bd-5f7a-4a5b-83ef-a0380248a4b6","name":"positive_conditioning","type":"conditioning"},"negative_conditioning":{"id":"9f61e901-d8a7-4d61-ae69-ff00e8dba6da","name":"negative_conditioning","type":"conditioning"},"noise":{"id":"622b98db-33d9-495b-bc60-d0c1cf268573","name":"noise","type":"latents"},"steps":{"id":"4176611a-7071-4138-9a31-6ff5a7fae44a","name":"steps","type":"integer","value":20},"cfg_scale":{"id":"5223049f-672e-4782-8b75-d80ea4d6116b","name":"cfg_scale","type":"float","value":7.5},"scheduler":{"id":"0368e8b4-eafd-4efc-9e8f-4b0587977a62","name":"scheduler","type":"enum","value":"euler"},"unet":{"id":"f3a6510b-9e5d-4fbc-bcf8-f66d6ad13508","name":"unet","type":"unet"},"latents":{"id":"5aa7d118-7b02-4efd-8010-e72f422088b7","name":"latents","type":"latents"},"denoising_start":{"id":"1e12482b-4c8b-45f2-9f03-b6018e8db6fb","name":"denoising_start","type":"float","value":0.7},"denoising_end":{"id":"59d159d3-0839-41c6-a57f-a1bd4d655634","name":"denoising_end","type":"float","value":1}},"outputs":{"latents":{"id":"990cd073-3cd6-49d2-b27b-e6d783db56e1","name":"latents","type":"latents"},"width":{"id":"310659f0-5ee9-4093-ba93-83d8abdc879f","name":"width","type":"integer"},"height":{"id":"a613cafe-fdb4-4347-a231-e77b3e2ba975","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":2044.2621231418175,"y":-374.36499568795915},"dragging":false},{"width":250,"height":323,"dragHandle":".node-drag-handle","id":"d1507d22-215b-4197-a893-c070f84c1eb2","type":"invocation","position":{"x":2564.7859493061546,"y":-740.6258381907046},"data":{"id":"d1507d22-215b-4197-a893-c070f84c1eb2","type":"l2i","inputs":{"latents":{"id":"206b269c-f27c-4ab9-9d97-80a26e6b5cc1","name":"latents","type":"latents"},"vae":{"id":"44e700f0-fe33-4d2d-ab74-3512f0625159","name":"vae","type":"vae"},"tiled":{"id":"0441b8f5-c6af-4461-8d9c-39abef4045f7","name":"tiled","type":"boolean","value":true},"fp32":{"id":"dcfa7f96-9d73-44cc-9cf5-418fd796d70c","name":"fp32","type":"boolean","value":true}},"outputs":{"image":{"id":"30c13692-27f8-4560-9ee8-188c4e4be266","name":"image","type":"image"},"width":{"id":"f12d0aee-6b61-4910-a4ef-14764a1dbaa3","name":"width","type":"integer"},"height":{"id":"3851e7e3-702a-4be9-9f51-176e0697c865","name":"height","type":"integer"}}},"selected":false,"positionAbsolute":{"x":2564.7859493061546,"y":-740.6258381907046},"dragging":false},{"width":320,"height":187,"dragHandle":".node-drag-handle","id":"1d7b5526-021f-4ade-bc7b-20173dd5e6f1","type":"invocation","position":{"x":110.88284993961776,"y":580.9044019817862},"data":{"id":"1d7b5526-021f-4ade-bc7b-20173dd5e6f1","type":"rand_int","inputs":{"low":{"id":"b05a55e0-ad8f-4629-9986-6ce27802c590","name":"low","type":"integer","value":0},"high":{"id":"4797e9b7-5d12-49e3-8f2c-64d80f203d42","name":"high","type":"integer","value":2147483647}},"outputs":{"a":{"id":"45fc7618-6803-4641-a976-36629852652d","name":"a","type":"integer"}}},"selected":false,"positionAbsolute":{"x":110.88284993961776,"y":580.9044019817862},"dragging":false},{"width":331,"height":138,"dragHandle":".node-drag-handle","id":"081ba10b-1fd0-4ad6-b4cc-b5aad5fec68e","type":"invocation","position":{"x":979.9163444732269,"y":267.53735270001346},"data":{"id":"081ba10b-1fd0-4ad6-b4cc-b5aad5fec68e","type":"param_float","inputs":{"param":{"id":"50bb0487-8138-415c-b3d4-54eedd70b980","name":"param","type":"float","value":0.7}},"outputs":{"param":{"id":"d3cf0272-d0fb-4085-bb52-9094352471a2","name":"param","type":"float"}}},"selected":false,"positionAbsolute":{"x":979.9163444732269,"y":267.53735270001346},"dragging":false}],"edges":[{"source":"2daddd1d-a468-4841-9ff4-57befb3518a1","sourceHandle":"text","target":"0af8fc93-be59-4002-9956-c700fb778d4a","targetHandle":"style","id":"reactflow__edge-2daddd1d-a468-4841-9ff4-57befb3518a1text-0af8fc93-be59-4002-9956-c700fb778d4astyle"},{"source":"2daddd1d-a468-4841-9ff4-57befb3518a1","sourceHandle":"text","target":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","targetHandle":"prompt","id":"reactflow__edge-2daddd1d-a468-4841-9ff4-57befb3518a1text-c1c4e1e7-ae79-4fc8-9a98-246175e7c155prompt"},{"source":"0cee1a14-34fa-4b5e-b361-9ebfaba62844","sourceHandle":"text","target":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","targetHandle":"style","id":"reactflow__edge-0cee1a14-34fa-4b5e-b361-9ebfaba62844text-c1c4e1e7-ae79-4fc8-9a98-246175e7c155style"},{"source":"3898e2dd-0efa-44cf-8d6c-0d23ef184419","sourceHandle":"text","target":"c5b420c9-82cd-4863-b416-a620009f2d36","targetHandle":"style","id":"reactflow__edge-3898e2dd-0efa-44cf-8d6c-0d23ef184419text-c5b420c9-82cd-4863-b416-a620009f2d36style"},{"source":"3898e2dd-0efa-44cf-8d6c-0d23ef184419","sourceHandle":"text","target":"addd5054-97d8-466c-ab3c-41f5e039af02","targetHandle":"prompt","id":"reactflow__edge-3898e2dd-0efa-44cf-8d6c-0d23ef184419text-addd5054-97d8-466c-ab3c-41f5e039af02prompt"},{"source":"96935e84-c864-48a4-b6c0-c940c71c43ae","sourceHandle":"text","target":"addd5054-97d8-466c-ab3c-41f5e039af02","targetHandle":"style","id":"reactflow__edge-96935e84-c864-48a4-b6c0-c940c71c43aetext-addd5054-97d8-466c-ab3c-41f5e039af02style"},{"source":"f3cd6592-f3a4-4d4e-a833-deb36314716f","sourceHandle":"unet","target":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","targetHandle":"unet","id":"reactflow__edge-f3cd6592-f3a4-4d4e-a833-deb36314716funet-0d025980-24fa-4b38-a5e6-e38c40ba23cfunet"},{"source":"f3cd6592-f3a4-4d4e-a833-deb36314716f","sourceHandle":"clip","target":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","targetHandle":"clip","id":"reactflow__edge-f3cd6592-f3a4-4d4e-a833-deb36314716fclip-c1c4e1e7-ae79-4fc8-9a98-246175e7c155clip"},{"source":"f3cd6592-f3a4-4d4e-a833-deb36314716f","sourceHandle":"clip","target":"addd5054-97d8-466c-ab3c-41f5e039af02","targetHandle":"clip","id":"reactflow__edge-f3cd6592-f3a4-4d4e-a833-deb36314716fclip-addd5054-97d8-466c-ab3c-41f5e039af02clip"},{"source":"f3cd6592-f3a4-4d4e-a833-deb36314716f","sourceHandle":"clip2","target":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","targetHandle":"clip2","id":"reactflow__edge-f3cd6592-f3a4-4d4e-a833-deb36314716fclip2-c1c4e1e7-ae79-4fc8-9a98-246175e7c155clip2"},{"source":"f3cd6592-f3a4-4d4e-a833-deb36314716f","sourceHandle":"clip2","target":"addd5054-97d8-466c-ab3c-41f5e039af02","targetHandle":"clip2","id":"reactflow__edge-f3cd6592-f3a4-4d4e-a833-deb36314716fclip2-addd5054-97d8-466c-ab3c-41f5e039af02clip2"},{"source":"c1c4e1e7-ae79-4fc8-9a98-246175e7c155","sourceHandle":"conditioning","target":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","targetHandle":"positive_conditioning","id":"reactflow__edge-c1c4e1e7-ae79-4fc8-9a98-246175e7c155conditioning-0d025980-24fa-4b38-a5e6-e38c40ba23cfpositive_conditioning"},{"source":"addd5054-97d8-466c-ab3c-41f5e039af02","sourceHandle":"conditioning","target":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","targetHandle":"negative_conditioning","id":"reactflow__edge-addd5054-97d8-466c-ab3c-41f5e039af02conditioning-0d025980-24fa-4b38-a5e6-e38c40ba23cfnegative_conditioning"},{"source":"fface4ab-25e3-4714-aca9-bb6c67842cd5","sourceHandle":"noise","target":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","targetHandle":"noise","id":"reactflow__edge-fface4ab-25e3-4714-aca9-bb6c67842cd5noise-0d025980-24fa-4b38-a5e6-e38c40ba23cfnoise"},{"source":"1d7b5526-021f-4ade-bc7b-20173dd5e6f1","sourceHandle":"a","target":"fface4ab-25e3-4714-aca9-bb6c67842cd5","targetHandle":"seed","id":"reactflow__edge-1d7b5526-021f-4ade-bc7b-20173dd5e6f1a-fface4ab-25e3-4714-aca9-bb6c67842cd5seed"},{"source":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","sourceHandle":"unet","target":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","targetHandle":"unet","id":"reactflow__edge-d9e4c4e6-de63-4ee1-b2e7-ad11d874bcaeunet-64fb34f7-6e0f-4fcf-80c3-9ca436c3623funet"},{"source":"0af8fc93-be59-4002-9956-c700fb778d4a","sourceHandle":"conditioning","target":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","targetHandle":"positive_conditioning","id":"reactflow__edge-0af8fc93-be59-4002-9956-c700fb778d4aconditioning-64fb34f7-6e0f-4fcf-80c3-9ca436c3623fpositive_conditioning"},{"source":"c5b420c9-82cd-4863-b416-a620009f2d36","sourceHandle":"conditioning","target":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","targetHandle":"negative_conditioning","id":"reactflow__edge-c5b420c9-82cd-4863-b416-a620009f2d36conditioning-64fb34f7-6e0f-4fcf-80c3-9ca436c3623fnegative_conditioning"},{"source":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","sourceHandle":"latents","target":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","targetHandle":"latents","id":"reactflow__edge-0d025980-24fa-4b38-a5e6-e38c40ba23cflatents-64fb34f7-6e0f-4fcf-80c3-9ca436c3623flatents"},{"source":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","sourceHandle":"latents","target":"d1507d22-215b-4197-a893-c070f84c1eb2","targetHandle":"latents","id":"reactflow__edge-64fb34f7-6e0f-4fcf-80c3-9ca436c3623flatents-d1507d22-215b-4197-a893-c070f84c1eb2latents"},{"source":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","sourceHandle":"vae","target":"d1507d22-215b-4197-a893-c070f84c1eb2","targetHandle":"vae","id":"reactflow__edge-d9e4c4e6-de63-4ee1-b2e7-ad11d874bcaevae-d1507d22-215b-4197-a893-c070f84c1eb2vae"},{"source":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","sourceHandle":"clip2","target":"0af8fc93-be59-4002-9956-c700fb778d4a","targetHandle":"clip2","id":"reactflow__edge-d9e4c4e6-de63-4ee1-b2e7-ad11d874bcaeclip2-0af8fc93-be59-4002-9956-c700fb778d4aclip2"},{"source":"d9e4c4e6-de63-4ee1-b2e7-ad11d874bcae","sourceHandle":"clip2","target":"c5b420c9-82cd-4863-b416-a620009f2d36","targetHandle":"clip2","id":"reactflow__edge-d9e4c4e6-de63-4ee1-b2e7-ad11d874bcaeclip2-c5b420c9-82cd-4863-b416-a620009f2d36clip2"},{"source":"081ba10b-1fd0-4ad6-b4cc-b5aad5fec68e","sourceHandle":"param","target":"0d025980-24fa-4b38-a5e6-e38c40ba23cf","targetHandle":"denoising_end","id":"reactflow__edge-081ba10b-1fd0-4ad6-b4cc-b5aad5fec68eparam-0d025980-24fa-4b38-a5e6-e38c40ba23cfdenoising_end"},{"source":"081ba10b-1fd0-4ad6-b4cc-b5aad5fec68e","sourceHandle":"param","target":"64fb34f7-6e0f-4fcf-80c3-9ca436c3623f","targetHandle":"denoising_start","id":"reactflow__edge-081ba10b-1fd0-4ad6-b4cc-b5aad5fec68eparam-64fb34f7-6e0f-4fcf-80c3-9ca436c3623fdenoising_start"}],"viewport":{"x":388.5523754198109,"y":521.3299012520417,"zoom":0.5336411821766158}}
\ No newline at end of file
diff --git a/docs/assets/send-to-icon.png b/docs/assets/send-to-icon.png
deleted file mode 100644
index 6ff1c9065b9..00000000000
Binary files a/docs/assets/send-to-icon.png and /dev/null differ
diff --git a/docs/assets/stable-samples/img2img/mountains-2.png b/docs/assets/stable-samples/img2img/mountains-2.png
deleted file mode 100644
index e9f4e708535..00000000000
Binary files a/docs/assets/stable-samples/img2img/mountains-2.png and /dev/null differ
diff --git a/docs/assets/stable-samples/img2img/mountains-3.png b/docs/assets/stable-samples/img2img/mountains-3.png
deleted file mode 100644
index 017de3012c2..00000000000
Binary files a/docs/assets/stable-samples/img2img/mountains-3.png and /dev/null differ
diff --git a/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg b/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg
deleted file mode 100644
index 79d652b8003..00000000000
Binary files a/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg and /dev/null differ
diff --git a/docs/assets/stable-samples/txt2img/merged-0005.png b/docs/assets/stable-samples/txt2img/merged-0005.png
deleted file mode 100644
index ca0a1af2065..00000000000
Binary files a/docs/assets/stable-samples/txt2img/merged-0005.png and /dev/null differ
diff --git a/docs/assets/stable-samples/txt2img/merged-0006.png b/docs/assets/stable-samples/txt2img/merged-0006.png
deleted file mode 100644
index 999f3703230..00000000000
Binary files a/docs/assets/stable-samples/txt2img/merged-0006.png and /dev/null differ
diff --git a/docs/assets/stable-samples/txt2img/merged-0007.png b/docs/assets/stable-samples/txt2img/merged-0007.png
deleted file mode 100644
index af390acaf60..00000000000
Binary files a/docs/assets/stable-samples/txt2img/merged-0007.png and /dev/null differ
diff --git a/docs/assets/step1.png b/docs/assets/step1.png
deleted file mode 100644
index 6309f41f206..00000000000
Binary files a/docs/assets/step1.png and /dev/null differ
diff --git a/docs/assets/step2.png b/docs/assets/step2.png
deleted file mode 100644
index 06027289b2e..00000000000
Binary files a/docs/assets/step2.png and /dev/null differ
diff --git a/docs/assets/step4.png b/docs/assets/step4.png
deleted file mode 100644
index c24db6b4702..00000000000
Binary files a/docs/assets/step4.png and /dev/null differ
diff --git a/docs/assets/step5.png b/docs/assets/step5.png
deleted file mode 100644
index b4e9b50576c..00000000000
Binary files a/docs/assets/step5.png and /dev/null differ
diff --git a/docs/assets/step6.png b/docs/assets/step6.png
deleted file mode 100644
index c43140c1aab..00000000000
Binary files a/docs/assets/step6.png and /dev/null differ
diff --git a/docs/assets/step7.png b/docs/assets/step7.png
deleted file mode 100644
index a575af28b22..00000000000
Binary files a/docs/assets/step7.png and /dev/null differ
diff --git a/docs/assets/still-life-inpainted.png b/docs/assets/still-life-inpainted.png
deleted file mode 100644
index ab8c7bd69a7..00000000000
Binary files a/docs/assets/still-life-inpainted.png and /dev/null differ
diff --git a/docs/assets/still-life-scaled.jpg b/docs/assets/still-life-scaled.jpg
deleted file mode 100644
index ba9c86be009..00000000000
Binary files a/docs/assets/still-life-scaled.jpg and /dev/null differ
diff --git a/docs/assets/textual-inversion/ti-frontend.png b/docs/assets/textual-inversion/ti-frontend.png
deleted file mode 100644
index 0500e9b1329..00000000000
Binary files a/docs/assets/textual-inversion/ti-frontend.png and /dev/null differ
diff --git a/docs/assets/troubleshooting/broken-dependency.png b/docs/assets/troubleshooting/broken-dependency.png
deleted file mode 100644
index 28415089204..00000000000
Binary files a/docs/assets/troubleshooting/broken-dependency.png and /dev/null differ
diff --git a/docs/assets/truncation_comparison.jpg b/docs/assets/truncation_comparison.jpg
deleted file mode 100644
index a39a804beb7..00000000000
Binary files a/docs/assets/truncation_comparison.jpg and /dev/null differ
diff --git a/docs/assets/upscaling.png b/docs/assets/upscaling.png
deleted file mode 100644
index e58a538e5ee..00000000000
Binary files a/docs/assets/upscaling.png and /dev/null differ
diff --git a/docs/assets/v1-variants-scores.jpg b/docs/assets/v1-variants-scores.jpg
deleted file mode 100644
index 9201b985d45..00000000000
Binary files a/docs/assets/v1-variants-scores.jpg and /dev/null differ
diff --git a/docs/assets/variation_walkthru/000001.3357757885.png b/docs/assets/variation_walkthru/000001.3357757885.png
deleted file mode 100644
index b9aa4a78edf..00000000000
Binary files a/docs/assets/variation_walkthru/000001.3357757885.png and /dev/null differ
diff --git a/docs/assets/variation_walkthru/000002.1614299449.png b/docs/assets/variation_walkthru/000002.1614299449.png
deleted file mode 100644
index 0db167ae6c1..00000000000
Binary files a/docs/assets/variation_walkthru/000002.1614299449.png and /dev/null differ
diff --git a/docs/assets/variation_walkthru/000002.3647897225.png b/docs/assets/variation_walkthru/000002.3647897225.png
deleted file mode 100644
index 7fe1f29227c..00000000000
Binary files a/docs/assets/variation_walkthru/000002.3647897225.png and /dev/null differ
diff --git a/docs/assets/variation_walkthru/000003.1614299449.png b/docs/assets/variation_walkthru/000003.1614299449.png
deleted file mode 100644
index b7f6ae76139..00000000000
Binary files a/docs/assets/variation_walkthru/000003.1614299449.png and /dev/null differ
diff --git a/docs/assets/variation_walkthru/000004.3747154981.png b/docs/assets/variation_walkthru/000004.3747154981.png
deleted file mode 100644
index e6ac5f3bc98..00000000000
Binary files a/docs/assets/variation_walkthru/000004.3747154981.png and /dev/null differ
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
new file mode 100644
index 00000000000..ebb59d36115
--- /dev/null
+++ b/docs/astro.config.mjs
@@ -0,0 +1,92 @@
+// @ts-check
+import { defineConfig } from 'astro/config';
+import starlight from '@astrojs/starlight';
+
+// Plugins
+import starlightLinksValidator from 'starlight-links-validator';
+import starlightLlmsText from 'starlight-llms-txt';
+import starlightChangelogs from 'starlight-changelogs';
+import { rehypePrefixBaseToRootLinks } from './plugins/rehype-prefix-base-to-root-links.mjs';
+import starlightContextualMenu from 'starlight-contextual-menu';
+
+// Configs
+import {
+ createHeadConfig,
+ createRedirects,
+ sidebarConfig,
+ socialConfig,
+} from './src/config';
+
+// Deployment target: 'custom' (default, custom domain at invoke.ai) or 'ghpages'
+// (GitHub Pages project URL at invoke-ai.github.io/InvokeAI). Drive site/base from this
+// so the same source can be deployed to either target.
+const deployTarget = process.env.DEPLOY_TARGET ?? 'custom';
+const isGhPages = deployTarget === 'ghpages';
+const enableAnalytics = process.env.ENABLE_ANALYTICS === 'true';
+const base = isGhPages ? '/InvokeAI' : '';
+const site = isGhPages ? 'https://invoke-ai.github.io' : 'https://invoke.ai';
+
+const redirects = createRedirects(base);
+const head = createHeadConfig({ base, enableAnalytics, isGhPages, site });
+
+// https://astro.build/config
+export default defineConfig({
+ site,
+ base: base || undefined,
+ markdown: {
+ rehypePlugins: [[rehypePrefixBaseToRootLinks, { base }]],
+ },
+ integrations: [
+ starlight({
+ // Content
+ title: {
+ en: 'InvokeAI Documentation',
+ },
+ logo: {
+ src: './src/assets/invoke-icon-wide.svg',
+ alt: 'InvokeAI Logo',
+ replacesTitle: true,
+ },
+ favicon: 'favicon.svg',
+ editLink: {
+ baseUrl: 'https://github.com/invoke-ai/InvokeAI/edit/main/docs',
+ },
+ head,
+ defaultLocale: 'root',
+ locales: {
+ root: {
+ label: 'English',
+ lang: 'en',
+ },
+ },
+ social: socialConfig,
+ tableOfContents: {
+ maxHeadingLevel: 4,
+ },
+ customCss: [
+ '@fontsource-variable/inter',
+ '@fontsource-variable/roboto-mono',
+ './src/styles/custom.css',
+ ],
+ sidebar: sidebarConfig,
+ components: {
+ ThemeProvider: './src/lib/components/ForceDarkTheme.astro',
+ ThemeSelect: './src/lib/components/EmptyComponent.astro',
+ Footer: './src/lib/components/Footer.astro',
+ PageFrame: './src/layouts/PageFrameExtended.astro',
+ },
+ plugins: [
+ starlightLinksValidator({
+ errorOnRelativeLinks: false,
+ errorOnLocalLinks: false,
+ }),
+ starlightLlmsText(),
+ starlightChangelogs(),
+ starlightContextualMenu({
+ actions: ['copy', 'view', 'chatgpt', 'claude'],
+ }),
+ ],
+ }),
+ ],
+ redirects,
+});
diff --git a/docs/contributing/ARCHITECTURE.md b/docs/contributing/ARCHITECTURE.md
deleted file mode 100644
index d74df94492c..00000000000
--- a/docs/contributing/ARCHITECTURE.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Invoke.AI Architecture
-
-```mermaid
-flowchart TB
-
- subgraph apps[Applications]
- webui[WebUI]
- cli[CLI]
-
- subgraph webapi[Web API]
- api[HTTP API]
- sio[Socket.IO]
- end
-
- end
-
- subgraph invoke[Invoke]
- direction LR
- invoker
- services
- sessions
- invocations
- end
-
- subgraph core[AI Core]
- Generate
- end
-
- webui --> webapi
- webapi --> invoke
- cli --> invoke
-
- invoker --> services & sessions
- invocations --> services
- sessions --> invocations
-
- services --> core
-
- %% Styles
- classDef sg fill:#5028C8,font-weight:bold,stroke-width:2,color:#fff,stroke:#14141A
- classDef default stroke-width:2px,stroke:#F6B314,color:#fff,fill:#14141A
-
- class apps,webapi,invoke,core sg
-
-```
-
-## Applications
-
-Applications are built on top of the invoke framework. They should construct `invoker` and then interact through it. They should avoid interacting directly with core code in order to support a variety of configurations.
-
-### Web UI
-
-The Web UI is built on top of an HTTP API built with [FastAPI](https://fastapi.tiangolo.com/) and [Socket.IO](https://socket.io/). The frontend code is found in `/frontend` and the backend code is found in `/ldm/invoke/app/api_app.py` and `/ldm/invoke/app/api/`. The code is further organized as such:
-
-| Component | Description |
-| --- | --- |
-| api_app.py | Sets up the API app, annotates the OpenAPI spec with additional data, and runs the API |
-| dependencies | Creates all invoker services and the invoker, and provides them to the API |
-| events | An eventing system that could in the future be adapted to support horizontal scale-out |
-| sockets | The Socket.IO interface - handles listening to and emitting session events (events are defined in the events service module) |
-| routers | API definitions for different areas of API functionality |
-
-### CLI
-
-The CLI is built automatically from invocation metadata, and also supports invocation piping and auto-linking. Code is available in `/ldm/invoke/app/cli_app.py`.
-
-## Invoke
-
-The Invoke framework provides the interface to the underlying AI systems and is built with flexibility and extensibility in mind. There are four major concepts: invoker, sessions, invocations, and services.
-
-### Invoker
-
-The invoker (`/ldm/invoke/app/services/invoker.py`) is the primary interface through which applications interact with the framework. Its primary purpose is to create, manage, and invoke sessions. It also maintains two sets of services:
-- **invocation services**, which are used by invocations to interact with core functionality.
-- **invoker services**, which are used by the invoker to manage sessions and manage the invocation queue.
-
-### Sessions
-
-Invocations and links between them form a graph, which is maintained in a session. Sessions can be queued for invocation, which will execute their graph (either the next ready invocation, or all invocations). Sessions also maintain execution history for the graph (including storage of any outputs). An invocation may be added to a session at any time, and there is capability to add and entire graph at once, as well as to automatically link new invocations to previous invocations. Invocations can not be deleted or modified once added.
-
-The session graph does not support looping. This is left as an application problem to prevent additional complexity in the graph.
-
-### Invocations
-
-Invocations represent individual units of execution, with inputs and outputs. All invocations are located in `/ldm/invoke/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.
-
-### Services
-
-Services provide invocations access AI Core functionality and other necessary functionality (e.g. image storage). These are available in `/ldm/invoke/app/services`. As a general rule, new services should provide an interface as an abstract base class, and may provide a lightweight local implementation by default in their module. The goal for all services should be to enable the usage of different implementations (e.g. using cloud storage for image storage), but should not load any module dependencies unless that implementation has been used (i.e. don't import anything that won't be used, especially if it's expensive to import).
-
-## AI Core
-
-The AI Core is represented by the rest of the code base (i.e. the code outside of `/ldm/invoke/app/`).
diff --git a/docs/contributing/CONTRIBUTING.md b/docs/contributing/CONTRIBUTING.md
deleted file mode 100644
index ccaf4f25610..00000000000
--- a/docs/contributing/CONTRIBUTING.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Contributing
-
-Invoke AI originated as a project built by the community, and that vision carries forward today as we aim to build the best pro-grade tools available. We work together to incorporate the latest in AI/ML research, making these tools available in over 20 languages to artists and creatives around the world as part of our fully permissive OSS project designed for individual users to self-host and use.
-
-
-# Methods of Contributing to Invoke AI
-Anyone who wishes to contribute to InvokeAI, whether features, bug fixes, code cleanup, testing, code reviews, documentation or translation is very much encouraged to do so.
-
-## Development
-If you’d like to help with development, please see our [development guide](contribution_guides/development.md).
-
-**New Contributors:** If you’re unfamiliar with contributing to open source projects, take a look at our [new contributor guide](contribution_guides/newContributorChecklist.md).
-
-## Nodes
-If you’d like to add a Node, please see our [nodes contribution guide](../nodes/contributingNodes.md).
-
-## Support and Triaging
-Helping support other users in [Discord](https://discord.gg/ZmtBAhwWhy) and on Github are valuable forms of contribution that we greatly appreciate.
-
-We receive many issues and requests for help from users. We're limited in bandwidth relative to our the user base, so providing answers to questions or helping identify causes of issues is very helpful. By doing this, you enable us to spend time on the highest priority work.
-
-## Documentation
-If you’d like to help with documentation, please see our [documentation guide](contribution_guides/documentation.md).
-
-## Translation
-If you'd like to help with translation, please see our [translation guide](contribution_guides/translation.md).
-
-## Tutorials
-Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI.
-
-We hope you enjoy using our software as much as we enjoy creating it, and we hope that some of those of you who are reading this will elect to become part of our contributor community.
-
-
-# Contributors
-
-This project is a combined effort of dedicated people from across the world. [Check out the list of all these amazing people](https://invoke-ai.github.io/InvokeAI/other/CONTRIBUTORS/). We thank them for their time, hard work and effort.
-
-# Code of Conduct
-
-The InvokeAI community is a welcoming place, and we want your help in maintaining that. Please review our [Code of Conduct](https://github.com/invoke-ai/InvokeAI/blob/main/CODE_OF_CONDUCT.md) to learn more - it's essential to maintaining a respectful and inclusive environment.
-
-By making a contribution to this project, you certify that:
-
-1. The contribution was created in whole or in part by you and you have the right to submit it under the open-source license indicated in this project’s GitHub repository; or
-2. The contribution is based upon previous work that, to the best of your knowledge, is covered under an appropriate open-source license and you have the right under that license to submit that work with modifications, whether created in whole or in part by you, under the same open-source license (unless you are permitted to submit under a different license); or
-3. The contribution was provided directly to you by some other person who certified (1) or (2) and you have not modified it; or
-4. You understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information you submit with it, including your sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open-source license(s) involved.
-
-This disclaimer is not a license and does not grant any rights or permissions. You must obtain necessary permissions and licenses, including from third parties, before contributing to this project.
-
-This disclaimer is provided "as is" without warranty of any kind, whether expressed or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, or non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the contribution or the use or other dealings in the contribution.
-# Support
-
-For support, please use this repository's [GitHub Issues](https://github.com/invoke-ai/InvokeAI/issues), or join the [Discord](https://discord.gg/ZmtBAhwWhy).
-
-Original portions of the software are Copyright (c) 2023 by respective contributors.
-
----
-
-Remember, your contributions help make this project great. We're excited to see what you'll bring to our community!
diff --git a/docs/contributing/DOWNLOAD_QUEUE.md b/docs/contributing/DOWNLOAD_QUEUE.md
deleted file mode 100644
index 960180961e9..00000000000
--- a/docs/contributing/DOWNLOAD_QUEUE.md
+++ /dev/null
@@ -1,334 +0,0 @@
-# The InvokeAI Download Queue
-
-The DownloadQueueService provides a multithreaded parallel download
-queue for arbitrary URLs, with queue prioritization, event handling,
-and restart capabilities.
-
-## Simple Example
-
-```
-from invokeai.app.services.download import DownloadQueueService, TqdmProgress
-
-download_queue = DownloadQueueService()
-for url in ['https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/a-painting-of-a-fire.png?raw=true',
- 'https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/birdhouse.png?raw=true',
- 'https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/missing.png',
- 'https://civitai.com/api/download/models/152309?type=Model&format=SafeTensor',
- ]:
-
- # urls start downloading as soon as download() is called
- download_queue.download(source=url,
- dest='/tmp/downloads',
- on_progress=TqdmProgress().update
- )
-
-download_queue.join() # wait for all downloads to finish
-for job in download_queue.list_jobs():
- print(job.model_dump_json(exclude_none=True, indent=4),"\n")
-```
-
-Output:
-
-```
-{
- "source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/a-painting-of-a-fire.png?raw=true",
- "dest": "/tmp/downloads",
- "id": 0,
- "priority": 10,
- "status": "completed",
- "download_path": "/tmp/downloads/a-painting-of-a-fire.png",
- "job_started": "2023-12-04T05:34:41.742174",
- "job_ended": "2023-12-04T05:34:42.592035",
- "bytes": 666734,
- "total_bytes": 666734
-}
-
-{
- "source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/birdhouse.png?raw=true",
- "dest": "/tmp/downloads",
- "id": 1,
- "priority": 10,
- "status": "completed",
- "download_path": "/tmp/downloads/birdhouse.png",
- "job_started": "2023-12-04T05:34:41.741975",
- "job_ended": "2023-12-04T05:34:42.652841",
- "bytes": 774949,
- "total_bytes": 774949
-}
-
-{
- "source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/missing.png",
- "dest": "/tmp/downloads",
- "id": 2,
- "priority": 10,
- "status": "error",
- "job_started": "2023-12-04T05:34:41.742079",
- "job_ended": "2023-12-04T05:34:42.147625",
- "bytes": 0,
- "total_bytes": 0,
- "error_type": "HTTPError(Not Found)",
- "error": "Traceback (most recent call last):\n File \"/home/lstein/Projects/InvokeAI/invokeai/app/services/download/download_default.py\", line 182, in _download_next_item\n self._do_download(job)\n File \"/home/lstein/Projects/InvokeAI/invokeai/app/services/download/download_default.py\", line 206, in _do_download\n raise HTTPError(resp.reason)\nrequests.exceptions.HTTPError: Not Found\n"
-}
-
-{
- "source": "https://civitai.com/api/download/models/152309?type=Model&format=SafeTensor",
- "dest": "/tmp/downloads",
- "id": 3,
- "priority": 10,
- "status": "completed",
- "download_path": "/tmp/downloads/xl_more_art-full_v1.safetensors",
- "job_started": "2023-12-04T05:34:42.147645",
- "job_ended": "2023-12-04T05:34:43.735990",
- "bytes": 719020768,
- "total_bytes": 719020768
-}
-```
-
-## The API
-
-The default download queue is `DownloadQueueService`, an
-implementation of ABC `DownloadQueueServiceBase`. It juggles multiple
-background download requests and provides facilities for interrogating
-and cancelling the requests. Access to a current or past download task
-is mediated via `DownloadJob` objects which report the current status
-of a job request
-
-### The Queue Object
-
-A default download queue is located in
-`ApiDependencies.invoker.services.download_queue`. However, you can
-create additional instances if you need to isolate your queue from the
-main one.
-
-```
-queue = DownloadQueueService(event_bus=events)
-```
-
-`DownloadQueueService()` takes three optional arguments:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| `max_parallel_dl` | int | 5 | Maximum number of simultaneous downloads allowed |
-| `event_bus` | EventServiceBase | None | System-wide FastAPI event bus for reporting download events |
-| `requests_session` | requests.sessions.Session | None | An alternative requests Session object to use for the download |
-
-`max_parallel_dl` specifies how many download jobs are allowed to run
-simultaneously. Each will run in a different thread of execution.
-
-`event_bus` is an EventServiceBase, typically the one created at
-InvokeAI startup. If present, download events are periodically emitted
-on this bus to allow clients to follow download progress.
-
-`requests_session` is a url library requests Session object. It is
-used for testing.
-
-### The Job object
-
-The queue operates on a series of download job objects. These objects
-specify the source and destination of the download, and keep track of
-the progress of the download.
-
-Two job types are defined. `DownloadJob` and
-`MultiFileDownloadJob`. The former is a pydantic object with the
-following fields:
-
-| **Field** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| _Fields passed in at job creation time_ |
-| `source` | AnyHttpUrl | | Where to download from |
-| `dest` | Path | | Where to download to |
-| `access_token` | str | | [optional] string containing authentication token for access |
-| `on_start` | Callable | | [optional] callback when the download starts |
-| `on_progress` | Callable | | [optional] callback called at intervals during download progress |
-| `on_complete` | Callable | | [optional] callback called after successful download completion |
-| `on_error` | Callable | | [optional] callback called after an error occurs |
-| `id` | int | auto assigned | Job ID, an integer >= 0 |
-| `priority` | int | 10 | Job priority. Lower priorities run before higher priorities |
-| |
-| _Fields updated over the course of the download task_
-| `status` | DownloadJobStatus| | Status code |
-| `download_path` | Path | | Path to the location of the downloaded file |
-| `job_started` | float | | Timestamp for when the job started running |
-| `job_ended` | float | | Timestamp for when the job completed or errored out |
-| `job_sequence` | int | | A counter that is incremented each time a model is dequeued |
-| `bytes` | int | 0 | Bytes downloaded so far |
-| `total_bytes` | int | 0 | Total size of the file at the remote site |
-| `error_type` | str | | String version of the exception that caused an error during download |
-| `error` | str | | String version of the traceback associated with an error |
-| `cancelled` | bool | False | Set to true if the job was cancelled by the caller|
-
-When you create a job, you can assign it a `priority`. If multiple
-jobs are queued, the job with the lowest priority runs first.
-
-Every job has a `source` and a `dest`. `source` is a pydantic.networks AnyHttpUrl object.
-The `dest` is a path on the local filesystem that specifies the
-destination for the downloaded object. Its semantics are
-described below.
-
-When the job is submitted, it is assigned a numeric `id`. The id can
-then be used to fetch the job object from the queue.
-
-The `status` field is updated by the queue to indicate where the job
-is in its lifecycle. Values are defined in the string enum
-`DownloadJobStatus`, a symbol available from
-`invokeai.app.services.download_manager`. Possible values are:
-
-| **Value** | **String Value** | ** Description ** |
-|--------------|---------------------|-------------------|
-| `WAITING` | waiting | Job is on the queue but not yet running|
-| `RUNNING` | running | The download is started |
-| `COMPLETED` | completed | Job has finished its work without an error |
-| `ERROR` | error | Job encountered an error and will not run again|
-
-`job_started` and `job_ended` indicate when the job
-was started (using a python timestamp) and when it completed.
-
-In case of an error, the job's status will be set to `DownloadJobStatus.ERROR`, the text of the
-Exception that caused the error will be placed in the `error_type`
-field and the traceback that led to the error will be in `error`.
-
-A cancelled job will have status `DownloadJobStatus.ERROR` and an
-`error_type` field of "DownloadJobCancelledException". In addition,
-the job's `cancelled` property will be set to True.
-
-The `MultiFileDownloadJob` is used for diffusers model downloads,
-which contain multiple files and directories under a common root:
-
-| **Field** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| _Fields passed in at job creation time_ |
-| `download_parts` | Set[DownloadJob]| | Component download jobs |
-| `dest` | Path | | Where to download to |
-| `on_start` | Callable | | [optional] callback when the download starts |
-| `on_progress` | Callable | | [optional] callback called at intervals during download progress |
-| `on_complete` | Callable | | [optional] callback called after successful download completion |
-| `on_error` | Callable | | [optional] callback called after an error occurs |
-| `id` | int | auto assigned | Job ID, an integer >= 0 |
-| _Fields updated over the course of the download task_
-| `status` | DownloadJobStatus| | Status code |
-| `download_path` | Path | | Path to the root of the downloaded files |
-| `bytes` | int | 0 | Bytes downloaded so far |
-| `total_bytes` | int | 0 | Total size of the file at the remote site |
-| `error_type` | str | | String version of the exception that caused an error during download |
-| `error` | str | | String version of the traceback associated with an error |
-| `cancelled` | bool | False | Set to true if the job was cancelled by the caller|
-
-Note that the MultiFileDownloadJob does not support the `priority`,
-`job_started`, `job_ended` or `content_type` attributes. You can get
-these from the individual download jobs in `download_parts`.
-
-
-### Callbacks
-
-Download jobs can be associated with a series of callbacks, each with
-the signature `Callable[["DownloadJob"], None]`. The callbacks are assigned
-using optional arguments `on_start`, `on_progress`, `on_complete` and
-`on_error`. When the corresponding event occurs, the callback wil be
-invoked and passed the job. The callback will be run in a `try:`
-context in the same thread as the download job. Any exceptions that
-occur during execution of the callback will be caught and converted
-into a log error message, thereby allowing the download to continue.
-
-#### `TqdmProgress`
-
-The `invokeai.app.services.download.download_default` module defines a
-class named `TqdmProgress` which can be used as an `on_progress`
-handler to display a completion bar in the console. Use as follows:
-
-```
-from invokeai.app.services.download import TqdmProgress
-
-download_queue.download(source='http://some.server.somewhere/some_file',
- dest='/tmp/downloads',
- on_progress=TqdmProgress().update
- )
-
-```
-
-### Events
-
-If the queue was initialized with the InvokeAI event bus (the case
-when using `ApiDependencies.invoker.services.download_queue`), then
-download events will also be issued on the bus. The events are:
-
-* `download_started` -- This is issued when a job is taken off the
-queue and a request is made to the remote server for the URL headers, but before any data
-has been downloaded. The event payload will contain the keys `source`
-and `download_path`. The latter contains the path that the URL will be
-downloaded to.
-
-* `download_progress -- This is issued periodically as the download
-runs. The payload contains the keys `source`, `download_path`,
-`current_bytes` and `total_bytes`. The latter two fields can be
-used to display the percent complete.
-
-* `download_complete` -- This is issued when the download completes
-successfully. The payload contains the keys `source`, `download_path`
-and `total_bytes`.
-
-* `download_error` -- This is issued when the download stops because
-of an error condition. The payload contains the fields `error_type`
-and `error`. The former is the text representation of the exception,
-and the latter is a traceback showing where the error occurred.
-
-### Job control
-
-To create a job call the queue's `download()` method. You can list all
-jobs using `list_jobs()`, fetch a single job by its with
-`id_to_job()`, cancel a running job with `cancel_job()`, cancel all
-running jobs with `cancel_all_jobs()`, and wait for all jobs to finish
-with `join()`.
-
-#### job = queue.download(source, dest, priority, access_token, on_start, on_progress, on_complete, on_cancelled, on_error)
-
-Create a new download job and put it on the queue, returning the
-DownloadJob object.
-
-#### multifile_job = queue.multifile_download(parts, dest, access_token, on_start, on_progress, on_complete, on_cancelled, on_error)
-
-This is similar to download(), but instead of taking a single source,
-it accepts a `parts` argument consisting of a list of
-`RemoteModelFile` objects. Each part corresponds to a URL/Path pair,
-where the URL is the location of the remote file, and the Path is the
-destination.
-
-`RemoteModelFile` can be imported from `invokeai.backend.model_manager.metadata`, and
-consists of a url/path pair. Note that the path *must* be relative.
-
-The method returns a `MultiFileDownloadJob`.
-
-
-```
-from invokeai.backend.model_manager.metadata import RemoteModelFile
-remote_file_1 = RemoteModelFile(url='http://www.foo.bar/my/pytorch_model.safetensors'',
- path='my_model/textencoder/pytorch_model.safetensors'
- )
-remote_file_2 = RemoteModelFile(url='http://www.bar.baz/vae.ckpt',
- path='my_model/vae/diffusers_model.safetensors'
- )
-job = queue.multifile_download(parts=[remote_file_1, remote_file_2],
- dest='/tmp/downloads',
- on_progress=TqdmProgress().update)
-queue.wait_for_job(job)
-print(f"The files were downloaded to {job.download_path}")
-```
-
-#### jobs = queue.list_jobs()
-
-Return a list of all active and inactive `DownloadJob`s.
-
-#### job = queue.id_to_job(id)
-
-Return the job corresponding to given ID.
-
-Return a list of all active and inactive `DownloadJob`s.
-
-#### queue.prune_jobs()
-
-Remove inactive (complete or errored) jobs from the listing returned
-by `list_jobs()`.
-
-#### queue.join()
-
-Block until all pending jobs have run to completion or errored out.
-
diff --git a/docs/contributing/INVOCATIONS.md b/docs/contributing/INVOCATIONS.md
deleted file mode 100644
index ce1ee9e808a..00000000000
--- a/docs/contributing/INVOCATIONS.md
+++ /dev/null
@@ -1,395 +0,0 @@
-# Nodes
-
-Features in InvokeAI are added in the form of modular nodes systems called
-**Invocations**.
-
-An Invocation is simply a single operation that takes in some inputs and gives
-out some outputs. We can then chain multiple Invocations together to create more
-complex functionality.
-
-## Invocations Directory
-
-InvokeAI Nodes can be found in the `invokeai/app/invocations` directory. These
-can be used as examples to create your own nodes.
-
-New nodes should be added to a subfolder in `nodes` direction found at the root
-level of the InvokeAI installation location. Nodes added to this folder will be
-able to be used upon application startup.
-
-Example `nodes` subfolder structure:
-
-```py
-├── __init__.py # Invoke-managed custom node loader
-│
-├── cool_node
-│ ├── __init__.py # see example below
-│ └── cool_node.py
-│
-└── my_node_pack
- ├── __init__.py # see example below
- ├── tasty_node.py
- ├── bodacious_node.py
- ├── utils.py
- └── extra_nodes
- └── fancy_node.py
-```
-
-Each node folder must have an `__init__.py` file that imports its nodes. Only
-nodes imported in the `__init__.py` file are loaded. See the README in the nodes
-folder for more examples:
-
-```py
-from .cool_node import CoolInvocation
-```
-
-## Creating A New Invocation
-
-In order to understand the process of creating a new Invocation, let us actually
-create one.
-
-In our example, let us create an Invocation that will take in an image, resize
-it and output the resized image.
-
-The first set of things we need to do when creating a new Invocation are -
-
-- Create a new class that derives from a predefined parent class called
- `BaseInvocation`.
-- Every Invocation must have a `docstring` that describes what this Invocation
- does.
-- While not strictly required, we suggest every invocation class name ends in
- "Invocation", eg "CropImageInvocation".
-- Every Invocation must use the `@invocation` decorator to provide its unique
- invocation type. You may also provide its title, tags and category using the
- decorator.
-- Invocations are strictly typed. We make use of the native
- [typing](https://docs.python.org/3/library/typing.html) library and the
- installed [pydantic](https://pydantic-docs.helpmanual.io/) library for
- validation.
-
-So let us do that.
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-
-@invocation('resize')
-class ResizeInvocation(BaseInvocation):
- '''Resizes an image'''
-```
-
-That's great.
-
-Now we have setup the base of our new Invocation. Let us think about what inputs
-our Invocation takes.
-
-- We need an `image` that we are going to resize.
-- We will need new `width` and `height` values to which we need to resize the
- image to.
-
-### **Inputs**
-
-Every Invocation input must be defined using the `InputField` function. This is
-a wrapper around the pydantic `Field` function, which handles a few extra things
-and provides type hints. Like everything else, this should be strictly typed and
-defined.
-
-So let us create these inputs for our Invocation. First up, the `image` input we
-need. Generally, we can use standard variable types in Python but InvokeAI
-already has a custom `ImageField` type that handles all the stuff that is needed
-for image inputs.
-
-But what is this `ImageField` ..? It is a special class type specifically
-written to handle how images are dealt with in InvokeAI. We will cover how to
-create your own custom field types later in this guide. For now, let's go ahead
-and use it.
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, InputField, invocation
-from invokeai.app.invocations.primitives import ImageField
-
-@invocation('resize')
-class ResizeInvocation(BaseInvocation):
-
- # Inputs
- image: ImageField = InputField(description="The input image")
-```
-
-Let us break down our input code.
-
-```python
-image: ImageField = InputField(description="The input image")
-```
-
-| Part | Value | Description |
-| --------- | ------------------------------------------- | ------------------------------------------------------------------------------- |
-| Name | `image` | The variable that will hold our image |
-| Type Hint | `ImageField` | The types for our field. Indicates that the image must be an `ImageField` type. |
-| Field | `InputField(description="The input image")` | The image variable is an `InputField` which needs a description. |
-
-Great. Now let us create our other inputs for `width` and `height`
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, InputField, invocation
-from invokeai.app.invocations.primitives import ImageField
-
-@invocation('resize')
-class ResizeInvocation(BaseInvocation):
- '''Resizes an image'''
-
- image: ImageField = InputField(description="The input image")
- width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
- height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
-```
-
-As you might have noticed, we added two new arguments to the `InputField`
-definition for `width` and `height`, called `gt` and `le`. They stand for
-_greater than or equal to_ and _less than or equal to_.
-
-These impose contraints on those fields, and will raise an exception if the
-values do not meet the constraints. Field constraints are provided by
-**pydantic**, so anything you see in the **pydantic docs** will work.
-
-**Note:** _Any time it is possible to define constraints for our field, we
-should do it so the frontend has more information on how to parse this field._
-
-Perfect. We now have our inputs. Let us do something with these.
-
-### **Invoke Function**
-
-The `invoke` function is where all the magic happens. This function provides you
-the `context` parameter that is of the type `InvocationContext` which will give
-you access to the current context of the generation and all the other services
-that are provided by it by InvokeAI.
-
-Let us create this function first.
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, InputField, invocation, InvocationContext
-from invokeai.app.invocations.primitives import ImageField
-
-@invocation('resize')
-class ResizeInvocation(BaseInvocation):
- '''Resizes an image'''
-
- image: ImageField = InputField(description="The input image")
- width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
- height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
-
- def invoke(self, context: InvocationContext):
- pass
-```
-
-### **Outputs**
-
-The output of our Invocation will be whatever is returned by this `invoke`
-function. Like with our inputs, we need to strongly type and define our outputs
-too.
-
-What is our output going to be? Another image. Normally you'd have to create a
-type for this but InvokeAI already offers you an `ImageOutput` type that handles
-all the necessary info related to image outputs. So let us use that.
-
-We will cover how to create your own output types later in this guide.
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, InputField, invocation, InvocationContext
-from invokeai.app.invocations.primitives import ImageField
-from invokeai.app.invocations.image import ImageOutput
-
-@invocation('resize')
-class ResizeInvocation(BaseInvocation):
- '''Resizes an image'''
-
- image: ImageField = InputField(description="The input image")
- width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
- height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
-
- def invoke(self, context: InvocationContext) -> ImageOutput:
- pass
-```
-
-Perfect. Now that we have our Invocation setup, let us do what we want to do.
-
-- We will first load the image using one of the services provided by InvokeAI to
- load the image.
-- We will resize the image using `PIL` to our input data.
-- We will output this image in the format we set above.
-
-So let's do that.
-
-```python
-from invokeai.app.invocations.baseinvocation import BaseInvocation, InputField, invocation, InvocationContext
-from invokeai.app.invocations.primitives import ImageField
-from invokeai.app.invocations.image import ImageOutput, ResourceOrigin, ImageCategory
-
-@invocation("resize")
-class ResizeInvocation(BaseInvocation):
- """Resizes an image"""
-
- image: ImageField = InputField(description="The input image")
- width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
- height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
-
- def invoke(self, context: InvocationContext) -> ImageOutput:
- # Load the input image as a PIL image
- image = context.images.get_pil(self.image.image_name)
-
- # Resize the image
- resized_image = image.resize((self.width, self.height))
-
- # Save the image
- image_dto = context.images.save(image=resized_image)
-
- # Return an ImageOutput
- return ImageOutput.build(image_dto)
-```
-
-**Note:** Do not be overwhelmed by the `ImageOutput` process. InvokeAI has a
-certain way that the images need to be dispatched in order to be stored and read
-correctly. In 99% of the cases when dealing with an image output, you can simply
-copy-paste the template above.
-
-### Customization
-
-We can use the `@invocation` decorator to provide some additional info to the
-UI, like a custom title, tags and category.
-
-We also encourage providing a version. This must be a
-[semver](https://semver.org/) version string ("$MAJOR.$MINOR.$PATCH"). The UI
-will let users know if their workflow is using a mismatched version of the node.
-
-```python
-@invocation("resize", title="My Resizer", tags=["resize", "image"], category="My Invocations", version="1.0.0")
-class ResizeInvocation(BaseInvocation):
- """Resizes an image"""
-
- image: ImageField = InputField(description="The input image")
- ...
-```
-
-That's it. You made your own **Resize Invocation**.
-
-## Result
-
-Once you make your Invocation correctly, the rest of the process is fully
-automated for you.
-
-When you launch InvokeAI, you can go to `http://localhost:9090/docs` and see
-your new Invocation show up there with all the relevant info.
-
-
-
-When you launch the frontend UI, you can go to the Node Editor tab and find your
-new Invocation ready to be used.
-
-
-
-## Contributing Nodes
-
-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) list. If you're not sure how to do that,
-take a look a at our [contributing nodes overview](contributingNodes).
-
-## Advanced
-
-### Custom Output Types
-
-Like with custom inputs, sometimes you might find yourself needing custom
-outputs that InvokeAI does not provide. We can easily set one up.
-
-Now that you are familiar with Invocations and Inputs, let us use that knowledge
-to create an output that has an `image` field, a `color` field and a `string`
-field.
-
-- An invocation output is a class that derives from the parent class of
- `BaseInvocationOutput`.
-- All invocation outputs must use the `@invocation_output` decorator to provide
- their unique output type.
-- Output fields must use the provided `OutputField` function. This is very
- similar to the `InputField` function described earlier - it's a wrapper around
- `pydantic`'s `Field()`.
-- It is not mandatory but we recommend using names ending with `Output` for
- output types.
-- It is not mandatory but we highly recommend adding a `docstring` to describe
- what your output type is for.
-
-Now that we know the basic rules for creating a new output type, let us go ahead
-and make it.
-
-```python
-from .baseinvocation import BaseInvocationOutput, OutputField, invocation_output
-from .primitives import ImageField, ColorField
-
-@invocation_output('image_color_string_output')
-class ImageColorStringOutput(BaseInvocationOutput):
- '''Base class for nodes that output a single image'''
-
- image: ImageField = OutputField(description="The image")
- color: ColorField = OutputField(description="The color")
- text: str = OutputField(description="The string")
-```
-
-That's all there is to it.
-
-### Custom Input Fields
-
-Now that you know how to create your own Invocations, let us dive into slightly
-more advanced topics.
-
-While creating your own Invocations, you might run into a scenario where the
-existing fields in InvokeAI do not meet your requirements. In such cases, you
-can create your own fields.
-
-Let us create one as an example. Let us say we want to create a color input
-field that represents a color code. But before we start on that here are some
-general good practices to keep in mind.
-
-### Best Practices
-
-- There is no naming convention for input fields but we highly recommend that
- you name it something appropriate like `ColorField`.
-- It is not mandatory but it is heavily recommended to add a relevant
- `docstring` to describe your field.
-- Keep your field in the same file as the Invocation that it is made for or in
- another file where it is relevant.
-
-All input types a class that derive from the `BaseModel` type from `pydantic`.
-So let's create one.
-
-```python
-from pydantic import BaseModel
-
-class ColorField(BaseModel):
- '''A field that holds the rgba values of a color'''
- pass
-```
-
-Perfect. Now let us create the properties for our field. This is similar to how
-you created input fields for your Invocation. All the same rules apply. Let us
-create four fields representing the _red(r)_, _blue(b)_, _green(g)_ and
-_alpha(a)_ channel of the color.
-
-> Technically, the properties are _also_ called fields - but in this case, it
-> refers to a `pydantic` field.
-
-```python
-class ColorField(BaseModel):
- '''A field that holds the rgba values of a color'''
- r: int = Field(ge=0, le=255, description="The red channel")
- g: int = Field(ge=0, le=255, description="The green channel")
- b: int = Field(ge=0, le=255, description="The blue channel")
- a: int = Field(ge=0, le=255, description="The alpha channel")
-```
-
-That's it. We now have a new input field type that we can use in our Invocations
-like this.
-
-```python
-color: ColorField = InputField(default=ColorField(r=0, g=0, b=0, a=0), description='Background color of an image')
-```
-
-### Using the custom field
-
-When you start the UI, your custom field will be automatically recognized.
-
-Custom fields only support connection inputs in the Workflow Editor.
diff --git a/docs/contributing/LOCAL_DEVELOPMENT.md b/docs/contributing/LOCAL_DEVELOPMENT.md
deleted file mode 100644
index 0136fbfcd08..00000000000
--- a/docs/contributing/LOCAL_DEVELOPMENT.md
+++ /dev/null
@@ -1,278 +0,0 @@
-# Local Development
-
-If you are looking to contribute you will need to have a local development
-environment. See the
-[Developer Install](../installation/020_INSTALL_MANUAL.md#developer-install) for
-full details.
-
-Broadly this involves cloning the repository, installing the pre-reqs, and
-InvokeAI (in editable form). Assuming this is working, choose your area of
-focus.
-
-## Documentation
-
-We use [mkdocs](https://www.mkdocs.org) for our documentation with the
-[material theme](https://squidfunk.github.io/mkdocs-material/). Documentation is
-written in markdown files under the `./docs` folder and then built into a static
-website for hosting with GitHub Pages at
-[invoke-ai.github.io/InvokeAI](https://invoke-ai.github.io/InvokeAI).
-
-To contribute to the documentation you'll need to install the dependencies. Note
-the use of `"`.
-
-```zsh
-pip install ".[docs]"
-```
-
-Now, to run the documentation locally with hot-reloading for changes made.
-
-```zsh
-mkdocs serve
-```
-
-You'll then be prompted to connect to `http://127.0.0.1:8080` in order to
-access.
-
-## Backend
-
-The backend is contained within the `./invokeai/backend` and `./invokeai/app` directories.
-To get started please install the development dependencies.
-
-From the root of the repository run the following command. Note the use of `"`.
-
-```zsh
-pip install ".[dev,test]"
-```
-
-These are optional groups of packages which are defined within the `pyproject.toml`
-and will be required for testing the changes you make to the code.
-
-### Tests
-
-See the [tests documentation](./TESTS.md) for information about running and writing tests.
-### Reloading Changes
-
-Experimenting with changes to the Python source code is a drag if you have to re-start the server —
-and re-load those multi-gigabyte models —
-after every change.
-
-For a faster development workflow, add the `--dev_reload` flag when starting the server.
-The server will watch for changes to all the Python files in the `invokeai` directory and apply those changes to the
-running server on the fly.
-
-This will allow you to avoid restarting the server (and reloading models) in most cases, but there are some caveats; see
-the [jurigged documentation](https://github.com/breuleux/jurigged#caveats) for details.
-
-
-## Front End
-
-
-
---8<-- "invokeai/frontend/web/README.md"
-
-## Developing InvokeAI in VSCode
-
-VSCode offers some nice tools:
-
-- python debugger
-- automatic `venv` activation
-- remote dev (e.g. run InvokeAI on a beefy linux desktop while you type in
- comfort on your macbook)
-
-### Setup
-
-You'll need the
-[Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
-and
-[Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)
-extensions installed first.
-
-It's also really handy to install the `Jupyter` extensions:
-
-- [Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter)
-- [Jupyter Cell Tags](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.vscode-jupyter-cell-tags)
-- [Jupyter Notebook Renderers](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter-renderers)
-- [Jupyter Slide Show](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.vscode-jupyter-slideshow)
-
-#### InvokeAI workspace
-
-Creating a VSCode workspace for working on InvokeAI is highly recommended. It
-can hold InvokeAI-specific settings and configs.
-
-To make a workspace:
-
-- Open the InvokeAI repo dir in VSCode
-- `File` > `Save Workspace As` > save it _outside_ the repo
-
-#### Default python interpreter (i.e. automatic virtual environment activation)
-
-- Use command palette to run command
- `Preferences: Open Workspace Settings (JSON)`
-- Add `python.defaultInterpreterPath` to `settings`, pointing to your `venv`'s
- python
-
-Should look something like this:
-
-```jsonc
-{
- // I like to have all InvokeAI-related folders in my workspace
- "folders": [
- {
- // repo root
- "path": "InvokeAI"
- },
- {
- // InvokeAI root dir, where `invokeai.yaml` lives
- "path": "/path/to/invokeai_root"
- }
- ],
- "settings": {
- // Where your InvokeAI `venv`'s python executable lives
- "python.defaultInterpreterPath": "/path/to/invokeai_root/.venv/bin/python"
- }
-}
-```
-
-Now when you open the VSCode integrated terminal, or do anything that needs to
-run python, it will automatically be in your InvokeAI virtual environment.
-
-Bonus: When you create a Jupyter notebook, when you run it, you'll be prompted
-for the python interpreter to run in. This will default to your `venv` python,
-and so you'll have access to the same python environment as the InvokeAI app.
-
-This is _super_ handy.
-
-#### Enabling Type-Checking with Pylance
-
-We use python's typing system in InvokeAI. PR reviews will include checking that types are present and correct. We don't enforce types with `mypy` at this time, but that is on the horizon.
-
-Using a code analysis tool to automatically type check your code (and types) is very important when writing with types. These tools provide immediate feedback in your editor when types are incorrect, and following their suggestions lead to fewer runtime bugs.
-
-Pylance, installed at the beginning of this guide, is the de-facto python LSP (language server protocol). It provides type checking in the editor (among many other features). Once installed, you do need to enable type checking manually:
-
-- Open a python file
-- Look along the status bar in VSCode for `{ } Python`
-- Click the `{ }`
-- Turn type checking on - basic is fine
-
-You'll now see red squiggly lines where type issues are detected. Hover your cursor over the indicated symbols to see what's wrong.
-
-In 99% of cases when the type checker says there is a problem, there really is a problem, and you should take some time to understand and resolve what it is pointing out.
-
-#### Debugging configs with `launch.json`
-
-Debugging configs are managed in a `launch.json` file. Like most VSCode configs,
-these can be scoped to a workspace or folder.
-
-Follow the [official guide](https://code.visualstudio.com/docs/python/debugging)
-to set up your `launch.json` and try it out.
-
-Now we can create the InvokeAI debugging configs:
-
-```jsonc
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- // Run the InvokeAI backend & serve the pre-built UI
- "name": "InvokeAI Web",
- "type": "python",
- "request": "launch",
- "program": "scripts/invokeai-web.py",
- "args": [
- // Your InvokeAI root dir (where `invokeai.yaml` lives)
- "--root",
- "/path/to/invokeai_root",
- // Access the app from anywhere on your local network
- "--host",
- "0.0.0.0"
- ],
- "justMyCode": true
- },
- {
- // Run the nodes-based CLI
- "name": "InvokeAI CLI",
- "type": "python",
- "request": "launch",
- "program": "scripts/invokeai-cli.py",
- "justMyCode": true
- },
- {
- // Run tests
- "name": "InvokeAI Test",
- "type": "python",
- "request": "launch",
- "module": "pytest",
- "args": ["--capture=no"],
- "justMyCode": true
- },
- {
- // Run a single test
- "name": "InvokeAI Single Test",
- "type": "python",
- "request": "launch",
- "module": "pytest",
- "args": [
- // Change this to point to the specific test you are working on
- "tests/nodes/test_invoker.py"
- ],
- "justMyCode": true
- },
- {
- // This is the default, useful to just run a single file
- "name": "Python: File",
- "type": "python",
- "request": "launch",
- "program": "${file}",
- "justMyCode": true
- }
- ]
-}
-```
-
-You'll see these configs in the debugging configs drop down. Running them will
-start InvokeAI with attached debugger, in the correct environment, and work just
-like the normal app.
-
-Enjoy debugging InvokeAI with ease (not that we have any bugs of course).
-
-#### Remote dev
-
-This is very easy to set up and provides the same very smooth experience as
-local development. Environments and debugging, as set up above, just work,
-though you'd need to recreate the workspace and debugging configs on the remote.
-
-Consult the
-[official guide](https://code.visualstudio.com/docs/remote/remote-overview) to
-get it set up.
-
-Suggest using VSCode's included settings sync so that your remote dev host has
-all the same app settings and extensions automagically.
-
-##### One remote dev gotcha
-
-I've found the automatic port forwarding to be very flakey. You can disable it
-in `Preferences: Open Remote Settings (ssh: hostname)`. Search for
-`remote.autoForwardPorts` and untick the box.
-
-To forward ports very reliably, use SSH on the remote dev client (e.g. your
-macbook). Here's how to forward both backend API port (`9090`) and the frontend
-live dev server port (`5173`):
-
-```bash
-ssh \
- -L 9090:localhost:9090 \
- -L 5173:localhost:5173 \
- user@remote-dev-host
-```
-
-The forwarding stops when you close the terminal window, so suggest to do this
-_outside_ the VSCode integrated terminal in case you need to restart VSCode for
-an extension update or something
-
-Now, on your remote dev client, you can open `localhost:9090` and access the UI,
-now served from the remote dev host, just the same as if it was running on the
-client.
diff --git a/docs/contributing/MODEL_MANAGER.md b/docs/contributing/MODEL_MANAGER.md
deleted file mode 100644
index 9699db4f1a6..00000000000
--- a/docs/contributing/MODEL_MANAGER.md
+++ /dev/null
@@ -1,1659 +0,0 @@
-# Introduction to the Model Manager V2
-
-The Model Manager is responsible for organizing the various machine
-learning models used by InvokeAI. It consists of a series of
-interdependent services that together handle the full lifecycle of a
-model. These are the:
-
-* _ModelRecordServiceBase_ Responsible for managing model metadata and
- configuration information. Among other things, the record service
- tracks the type of the model, its provenance, and where it can be
- found on disk.
-
-* _ModelInstallServiceBase_ A service for installing models to
- disk. It uses `DownloadQueueServiceBase` to download models and
- their metadata, and `ModelRecordServiceBase` to store that
- information. It is also responsible for managing the InvokeAI
- `models` directory and its contents.
-
-* _DownloadQueueServiceBase_
- A multithreaded downloader responsible
- for downloading models from a remote source to disk. The download
- queue has special methods for downloading repo_id folders from
- Hugging Face, as well as discriminating among model versions in
- Civitai, but can be used for arbitrary content.
-
- * _ModelLoadServiceBase_
- Responsible for loading a model from disk
- into RAM and VRAM and getting it ready for inference.
-
-## Location of the Code
-
-The four main services can be found in
-`invokeai/app/services` in the following directories:
-
-* `invokeai/app/services/model_records/`
-* `invokeai/app/services/model_install/`
-* `invokeai/app/services/downloads/`
-* `invokeai/app/services/model_load/`
-
-Code related to the FastAPI web API can be found in
-`invokeai/app/api/routers/model_manager_v2.py`.
-
-***
-
-## What's in a Model? The ModelRecordService
-
-The `ModelRecordService` manages the model's metadata. It supports a
-hierarchy of pydantic metadata "config" objects, which become
-increasingly specialized to support particular model types.
-
-### ModelConfigBase
-
-All model metadata classes inherit from this pydantic class. it
-provides the following fields:
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `key` | str | Unique identifier for the model |
-| `name` | str | Name of the model (not unique) |
-| `model_type` | ModelType | The type of the model |
-| `model_format` | ModelFormat | The format of the model (e.g. "diffusers"); also used as a Union discriminator |
-| `base_model` | BaseModelType | The base model that the model is compatible with |
-| `path` | str | Location of model on disk |
-| `hash` | str | Hash of the model |
-| `description` | str | Human-readable description of the model (optional) |
-| `source` | str | Model's source URL or repo id (optional) |
-
-The `key` is a unique 32-character random ID which was generated at
-install time. The `hash` field stores a hash of the model's
-contents at install time obtained by sampling several parts of the
-model's files using the `imohash` library. Over the course of the
-model's lifetime it may be transformed in various ways, such as
-changing its precision or converting it from a .safetensors to a
-diffusers model.
-
-`ModelType`, `ModelFormat` and `BaseModelType` are string enums that
-are defined in `invokeai.backend.model_manager.config`. They are also
-imported by, and can be reexported from,
-`invokeai.app.services.model_manager.model_records`:
-
-```
-from invokeai.app.services.model_records import ModelType, ModelFormat, BaseModelType
-```
-
-The `path` field can be absolute or relative. If relative, it is taken
-to be relative to the `models_dir` setting in the user's
-`invokeai.yaml` file.
-
-### CheckpointConfig
-
-This adds support for checkpoint configurations, and adds the
-following field:
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `config` | str | Path to the checkpoint's config file |
-
-`config` is the path to the checkpoint's config file. If relative, it
-is taken to be relative to the InvokeAI root directory
-(e.g. `configs/stable-diffusion/v1-inference.yaml`)
-
-### MainConfig
-
-This adds support for "main" Stable Diffusion models, and adds these
-fields:
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `vae` | str | Path to a VAE to use instead of the burnt-in one |
-| `variant` | ModelVariantType| Model variant type, such as "inpainting" |
-
-`vae` can be an absolute or relative path. If relative, its base is
-taken to be the `models_dir` directory.
-
-`variant` is an enumerated string class with values `normal`,
-`inpaint` and `depth`. If needed, it can be imported if needed from
-either `invokeai.app.services.model_records` or
-`invokeai.backend.model_manager.config`.
-
-### ONNXSD2Config
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `prediction_type` | SchedulerPredictionType | Scheduler prediction type to use, e.g. "epsilon" |
-| `upcast_attention` | bool | Model requires its attention module to be upcast |
-
-The `SchedulerPredictionType` enum can be imported from either
-`invokeai.app.services.model_records` or
-`invokeai.backend.model_manager.config`.
-
-### Other config classes
-
-There are a series of such classes each discriminated by their
-`ModelFormat`, including `LoRAConfig`, `IPAdapterConfig`, and so
-forth. These are rarely needed outside the model manager's internal
-code, but available in `invokeai.backend.model_manager.config` if
-needed. There is also a Union of all ModelConfig classes, called
-`AnyModelConfig` that can be imported from the same file.
-
-### Limitations of the Data Model
-
-The config hierarchy has a major limitation in its handling of the
-base model type. Each model can only be compatible with one base
-model, which breaks down in the event of models that are compatible
-with two or more base models. For example, SD-1 VAEs also work with
-SD-2 models. A partial workaround is to use `BaseModelType.Any`, which
-indicates that the model is compatible with any of the base
-models. This works OK for some models, such as the IP Adapter image
-encoders, but is an all-or-nothing proposition.
-
-## Reading and Writing Model Configuration Records
-
-The `ModelRecordService` provides the ability to retrieve model
-configuration records from SQL or YAML databases, update them, and
-write them back.
-
-A application-wide `ModelRecordService` is created during API
-initialization and can be retrieved within an invocation from the
-`InvocationContext` object:
-
-```
-store = context.services.model_manager.store
-```
-
-or from elsewhere in the code by accessing
-`ApiDependencies.invoker.services.model_manager.store`.
-
-### Creating a `ModelRecordService`
-
-To create a new `ModelRecordService` database or open an existing one,
-you can directly create either a `ModelRecordServiceSQL` or a
-`ModelRecordServiceFile` object:
-
-```
-from invokeai.app.services.model_records import ModelRecordServiceSQL, ModelRecordServiceFile
-
-store = ModelRecordServiceSQL.from_connection(connection, lock)
-store = ModelRecordServiceSQL.from_db_file('/path/to/sqlite_database.db')
-store = ModelRecordServiceFile.from_db_file('/path/to/database.yaml')
-```
-
-The `from_connection()` form is only available from the
-`ModelRecordServiceSQL` class, and is used to manage records in a
-previously-opened SQLITE3 database using a `sqlite3.connection` object
-and a `threading.lock` object. It is intended for the specific use
-case of storing the record information in the main InvokeAI database,
-usually `databases/invokeai.db`.
-
-The `from_db_file()` methods can be used to open new connections to
-the named database files. If the file doesn't exist, it will be
-created and initialized.
-
-As a convenience, `ModelRecordServiceBase` offers two methods,
-`from_db_file` and `open`, which will return either a SQL or File
-implementation depending on the context. The former looks at the file
-extension to determine whether to open the file as a SQL database
-(".db") or as a file database (".yaml"). If the file exists, but is
-either the wrong type or does not contain the expected schema
-metainformation, then an appropriate `AssertionError` will be raised:
-
-```
-store = ModelRecordServiceBase.from_db_file('/path/to/a/file.{yaml,db}')
-```
-
-The `ModelRecordServiceBase.open()` method is specifically designed
-for use in the InvokeAI web server. Its signature is:
-
-```
-def open(
- cls,
- config: InvokeAIAppConfig,
- conn: Optional[sqlite3.Connection] = None,
- lock: Optional[threading.Lock] = None
- ) -> Union[ModelRecordServiceSQL, ModelRecordServiceFile]:
-```
-
-The way it works is as follows:
-
-1. Retrieve the value of the `model_config_db` option from the user's
- `invokeai.yaml` config file.
-2. If `model_config_db` is `auto` (the default), then:
- * Use the values of `conn` and `lock` to return a `ModelRecordServiceSQL` object
- opened on the passed connection and lock.
- * Open up a new connection to `databases/invokeai.db` if `conn`
- and/or `lock` are missing (see note below).
-3. If `model_config_db` is a Path, then use `from_db_file`
- to return the appropriate type of ModelRecordService.
-4. If `model_config_db` is None, then retrieve the legacy
- `conf_path` option from `invokeai.yaml` and use the Path
- indicated there. This will default to `configs/models.yaml`.
-
-So a typical startup pattern would be:
-
-```
-import sqlite3
-from invokeai.app.services.thread import lock
-from invokeai.app.services.model_records import ModelRecordServiceBase
-from invokeai.app.services.config import InvokeAIAppConfig
-
-config = InvokeAIAppConfig.get_config()
-db_conn = sqlite3.connect(config.db_path.as_posix(), check_same_thread=False)
-store = ModelRecordServiceBase.open(config, db_conn, lock)
-```
-
-### Fetching a Model's Configuration from `ModelRecordServiceBase`
-
-Configurations can be retrieved in several ways.
-
-#### get_model(key) -> AnyModelConfig
-
-The basic functionality is to call the record store object's
-`get_model()` method with the desired model's unique key. It returns
-the appropriate subclass of ModelConfigBase:
-
-```
-model_conf = store.get_model('f13dd932c0c35c22dcb8d6cda4203764')
-print(model_conf.path)
-
->> '/tmp/models/ckpts/v1-5-pruned-emaonly.safetensors'
-
-```
-
-If the key is unrecognized, this call raises an
-`UnknownModelException`.
-
-#### exists(key) -> AnyModelConfig
-
-Returns True if a model with the given key exists in the databsae.
-
-#### search_by_path(path) -> AnyModelConfig
-
-Returns the configuration of the model whose path is `path`. The path
-is matched using a simple string comparison and won't correctly match
-models referred to by different paths (e.g. using symbolic links).
-
-#### search_by_name(name, base, type) -> List[AnyModelConfig]
-
-This method searches for models that match some combination of `name`,
-`BaseType` and `ModelType`. Calling without any arguments will return
-all the models in the database.
-
-#### all_models() -> List[AnyModelConfig]
-
-Return all the model configs in the database. Exactly equivalent to
-calling `search_by_name()` with no arguments.
-
-#### search_by_tag(tags) -> List[AnyModelConfig]
-
-`tags` is a list of strings. This method returns a list of model
-configs that contain all of the given tags. Examples:
-
-```
-# find all models that are marked as both SFW and as generating
-# background scenery
-configs = store.search_by_tag(['sfw', 'scenery'])
-```
-
-Note that only tags are not searchable in this way. Other fields can
-be searched using a filter:
-
-```
-commercializable_models = [x for x in store.all_models() \
- if x.license.contains('allowCommercialUse=Sell')]
-```
-
-#### version() -> str
-
-Returns the version of the database, currently at `3.2`
-
-#### model_info_by_name(name, base_model, model_type) -> ModelConfigBase
-
-This method exists to ease the transition from the previous version of
-the model manager, in which `get_model()` took the three arguments
-shown above. This looks for a unique model identified by name, base
-model and model type and returns it.
-
-The method will generate a `DuplicateModelException` if there are more
-than one models that share the same type, base and name. While
-unlikely, it is certainly possible to have a situation in which the
-user had added two models with the same name, base and type, one
-located at path `/foo/my_model` and the other at `/bar/my_model`. It
-is strongly recommended to search for models using `search_by_name()`,
-which can return multiple results, and then to select the desired
-model and pass its key to `get_model()`.
-
-### Writing model configs to the database
-
-Several methods allow you to create and update stored model config
-records.
-
-#### add_model(key, config) -> AnyModelConfig
-
-Given a key and a configuration, this will add the model's
-configuration record to the database. `config` can either be a subclass of
-`ModelConfigBase` (i.e. any class listed in `AnyModelConfig`), or a
-`dict` of key/value pairs. In the latter case, the correct
-configuration class will be picked by Pydantic's discriminated union
-mechanism.
-
-If successful, the method will return the appropriate subclass of
-`ModelConfigBase`. It will raise a `DuplicateModelException` if a
-model with the same key is already in the database, or an
-`InvalidModelConfigException` if a dict was passed and Pydantic
-experienced a parse or validation error.
-
-### update_model(key, config) -> AnyModelConfig
-
-Given a key and a configuration, this will update the model
-configuration record in the database. `config` can be either a
-instance of `ModelConfigBase`, or a sparse `dict` containing the
-fields to be updated. This will return an `AnyModelConfig` on success,
-or raise `InvalidModelConfigException` or `UnknownModelException`
-exceptions on failure.
-
-***
-
-## Model installation
-
-The `ModelInstallService` class implements the
-`ModelInstallServiceBase` abstract base class, and provides a one-stop
-shop for all your model install needs. It provides the following
-functionality:
-
-* Registering a model config record for a model already located on the
- local filesystem, without moving it or changing its path.
-
-* Installing a model alreadiy located on the local filesystem, by
- moving it into the InvokeAI root directory under the
- `models` folder (or wherever config parameter `models_dir`
- specifies).
-
-* Probing of models to determine their type, base type and other key
- information.
-
-* Interface with the InvokeAI event bus to provide status updates on
- the download, installation and registration process.
-
-* Downloading a model from an arbitrary URL and installing it in
- `models_dir`.
-
-* Special handling for HuggingFace repo_ids to recursively download
- the contents of the repository, paying attention to alternative
- variants such as fp16.
-
-* Saving tags and other metadata about the model into the invokeai database
- when fetching from a repo that provides that type of information,
- (currently only HuggingFace).
-
-### Initializing the installer
-
-A default installer is created at InvokeAI api startup time and stored
-in `ApiDependencies.invoker.services.model_install` and can
-also be retrieved from an invocation's `context` argument with
-`context.services.model_install`.
-
-In the event you wish to create a new installer, you may use the
-following initialization pattern:
-
-```
-from invokeai.app.services.config import get_config
-from invokeai.app.services.model_records import ModelRecordServiceSQL
-from invokeai.app.services.model_install import ModelInstallService
-from invokeai.app.services.download import DownloadQueueService
-from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
-from invokeai.backend.util.logging import InvokeAILogger
-
-config = get_config()
-
-logger = InvokeAILogger.get_logger(config=config)
-db = SqliteDatabase(config.db_path, logger)
-record_store = ModelRecordServiceSQL(db)
-queue = DownloadQueueService()
-queue.start()
-
-installer = ModelInstallService(app_config=config,
- record_store=record_store,
- download_queue=queue
- )
-installer.start()
-```
-
-The full form of `ModelInstallService()` takes the following
-required parameters:
-
-| **Argument** | **Type** | **Description** |
-|------------------|------------------------------|------------------------------|
-| `app_config` | InvokeAIAppConfig | InvokeAI app configuration object |
-| `record_store` | ModelRecordServiceBase | Config record storage database |
-| `download_queue` | DownloadQueueServiceBase | Download queue object |
-|`session` | Optional[requests.Session] | Swap in a different Session object (usually for debugging) |
-
-Once initialized, the installer will provide the following methods:
-
-#### install_job = installer.heuristic_import(source, [config], [access_token])
-
-This is a simplified interface to the installer which takes a source
-string, an optional model configuration dictionary and an optional
-access token.
-
-The `source` is a string that can be any of these forms
-
-1. A path on the local filesystem (`C:\\users\\fred\\model.safetensors`)
-2. A Url pointing to a single downloadable model file (`https://civitai.com/models/58390/detail-tweaker-lora-lora`)
-3. A HuggingFace repo_id with any of the following formats:
- * `model/name` -- entire model
- * `model/name:fp32` -- entire model, using the fp32 variant
- * `model/name:fp16:vae` -- vae submodel, using the fp16 variant
- * `model/name::vae` -- vae submodel, using default precision
- * `model/name:fp16:path/to/model.safetensors` -- an individual model file, fp16 variant
- * `model/name::path/to/model.safetensors` -- an individual model file, default variant
-
-Note that by specifying a relative path to the top of the HuggingFace
-repo, you can download and install arbitrary models files.
-
-The variant, if not provided, will be automatically filled in with
-`fp32` if the user has requested full precision, and `fp16`
-otherwise. If a variant that does not exist is requested, then the
-method will install whatever HuggingFace returns as its default
-revision.
-
-`config` is an optional dict of values that will override the
-autoprobed values for model type, base, scheduler prediction type, and
-so forth. See [Model configuration and
-probing](#Model-configuration-and-probing) for details.
-
-`access_token` is an optional access token for accessing resources
-that need authentication.
-
-The method will return a `ModelInstallJob`. This object is discussed
-at length in the following section.
-
-#### install_job = installer.import_model()
-
-The `import_model()` method is the core of the installer. The
-following illustrates basic usage:
-
-```
-from invokeai.app.services.model_install import (
- LocalModelSource,
- HFModelSource,
- URLModelSource,
-)
-
-source1 = LocalModelSource(path='/opt/models/sushi.safetensors') # a local safetensors file
-source2 = LocalModelSource(path='/opt/models/sushi_diffusers') # a local diffusers folder
-
-source3 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5') # a repo_id
-source4 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', subfolder='vae') # a subfolder within a repo_id
-source5 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', variant='fp16') # a named variant of a HF model
-source6 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', subfolder='OrangeMix/OrangeMix1.ckpt') # path to an individual model file
-
-source7 = URLModelSource(url='https://civitai.com/api/download/models/63006') # model located at a URL
-source8 = URLModelSource(url='https://civitai.com/api/download/models/63006', access_token='letmein') # with an access token
-
-for source in [source1, source2, source3, source4, source5, source6, source7]:
- install_job = installer.install_model(source)
-
-source2job = installer.wait_for_installs(timeout=120)
-for source in sources:
- job = source2job[source]
- if job.complete:
- model_config = job.config_out
- model_key = model_config.key
- print(f"{source} installed as {model_key}")
- elif job.errored:
- print(f"{source}: {job.error_type}.\nStack trace:\n{job.error}")
-
-```
-
-As shown here, the `import_model()` method accepts a variety of
-sources, including local safetensors files, local diffusers folders,
-HuggingFace repo_ids with and without a subfolder designation,
-Civitai model URLs and arbitrary URLs that point to checkpoint files
-(but not to folders).
-
-Each call to `import_model()` return a `ModelInstallJob` job,
-an object which tracks the progress of the install.
-
-If a remote model is requested, the model's files are downloaded in
-parallel across a multiple set of threads using the download
-queue. During the download process, the `ModelInstallJob` is updated
-to provide status and progress information. After the files (if any)
-are downloaded, the remainder of the installation runs in a single
-serialized background thread. These are the model probing, file
-copying, and config record database update steps.
-
-Multiple install jobs can be queued up. You may block until all
-install jobs are completed (or errored) by calling the
-`wait_for_installs()` method as shown in the code
-example. `wait_for_installs()` will return a `dict` that maps the
-requested source to its job. This object can be interrogated
-to determine its status. If the job errored out, then the error type
-and details can be recovered from `job.error_type` and `job.error`.
-
-The full list of arguments to `import_model()` is as follows:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|------------------|------------------------------|-------------|-------------------------------------------|
-| `source` | ModelSource | None | The source of the model, Path, URL or repo_id |
-| `config` | Dict[str, Any] | None | Override all or a portion of model's probed attributes |
-
-The next few sections describe the various types of ModelSource that
-can be passed to `import_model()`.
-
-`config` can be used to override all or a portion of the configuration
-attributes returned by the model prober. See the section below for
-details.
-
-#### LocalModelSource
-
-This is used for a model that is located on a locally-accessible Posix
-filesystem, such as a local disk or networked fileshare.
-
-| **Argument** | **Type** | **Default** | **Description** |
-|------------------|------------------------------|-------------|-------------------------------------------|
-| `path` | str | Path | None | Path to the model file or directory |
-| `inplace` | bool | False | If set, the model file(s) will be left in their location; otherwise they will be copied into the InvokeAI root's `models` directory |
-
-#### URLModelSource
-
-This is used for a single-file model that is accessible via a URL. The
-fields are:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|------------------|------------------------------|-------------|-------------------------------------------|
-| `url` | AnyHttpUrl | None | The URL for the model file. |
-| `access_token` | str | None | An access token needed to gain access to this file. |
-
-The `AnyHttpUrl` class can be imported from `pydantic.networks`.
-
-Ordinarily, no metadata is retrieved from these sources. However,
-there is special-case code in the installer that looks for HuggingFace
-and fetches the corresponding model metadata from the corresponding repo.
-
-#### HFModelSource
-
-HuggingFace has the most complicated `ModelSource` structure:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|------------------|------------------------------|-------------|-------------------------------------------|
-| `repo_id` | str | None | The ID of the desired model. |
-| `variant` | ModelRepoVariant | ModelRepoVariant('fp16') | The desired variant. |
-| `subfolder` | Path | None | Look for the model in a subfolder of the repo. |
-| `access_token` | str | None | An access token needed to gain access to a subscriber's-only model. |
-
-The `repo_id` is the repository ID, such as `stabilityai/sdxl-turbo`.
-
-The `variant` is one of the various diffusers formats that HuggingFace
-supports and is used to pick out from the hodgepodge of files that in
-a typical HuggingFace repository the particular components needed for
-a complete diffusers model. `ModelRepoVariant` is an enum that can be
-imported from `invokeai.backend.model_manager` and has the following
-values:
-
-| **Name** | **String Value** |
-|----------------------------|---------------------------|
-| ModelRepoVariant.DEFAULT | "default" |
-| ModelRepoVariant.FP16 | "fp16" |
-| ModelRepoVariant.FP32 | "fp32" |
-| ModelRepoVariant.ONNX | "onnx" |
-| ModelRepoVariant.OPENVINO | "openvino" |
-| ModelRepoVariant.FLAX | "flax" |
-
-You can also pass the string forms to `variant` directly. Note that
-InvokeAI may not be able to load and run all variants. At the current
-time, specifying `ModelRepoVariant.DEFAULT` will retrieve model files
-that are unqualified, e.g. `pytorch_model.safetensors` rather than
-`pytorch_model.fp16.safetensors`. These are usually the 32-bit
-safetensors forms of the model.
-
-If `subfolder` is specified, then the requested model resides in a
-subfolder of the main model repository. This is typically used to
-fetch and install VAEs.
-
-Some models require you to be registered with HuggingFace and logged
-in. To download these files, you must provide an
-`access_token`. Internally, if no access token is provided, then
-`HfFolder.get_token()` will be called to fill it in with the cached
-one.
-
-#### Monitoring the install job process
-
-When you create an install job with `import_model()`, it launches the
-download and installation process in the background and returns a
-`ModelInstallJob` object for monitoring the process.
-
-The `ModelInstallJob` class has the following structure:
-
-| **Attribute** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `id` | `int` | Integer ID for this job |
-| `status` | `InstallStatus` | An enum of [`waiting`, `downloading`, `running`, `completed`, `error` and `cancelled`]|
-| `config_in` | `dict` | Overriding configuration values provided by the caller |
-| `config_out` | `AnyModelConfig`| After successful completion, contains the configuration record written to the database |
-| `inplace` | `boolean` | True if the caller asked to install the model in place using its local path |
-| `source` | `ModelSource` | The local path, remote URL or repo_id of the model to be installed |
-| `local_path` | `Path` | If a remote model, holds the path of the model after it is downloaded; if a local model, same as `source` |
-| `error_type` | `str` | Name of the exception that led to an error status |
-| `error` | `str` | Traceback of the error |
-
-If the `event_bus` argument was provided, events will also be
-broadcast to the InvokeAI event bus. The events will appear on the bus
-as an event of type `EventServiceBase.model_event`, a timestamp and
-the following event names:
-
-##### `model_install_downloading`
-
-For remote models only, `model_install_downloading` events will be issued at regular
-intervals as the download progresses. The event's payload contains the
-following keys:
-
-| **Key** | **Type** | **Description** |
-|----------------|-----------|------------------|
-| `source` | str | String representation of the requested source |
-| `local_path` | str | String representation of the path to the downloading model (usually a temporary directory) |
-| `bytes` | int | How many bytes downloaded so far |
-| `total_bytes` | int | Total size of all the files that make up the model |
-| `parts` | List[Dict]| Information on the progress of the individual files that make up the model |
-
-The parts is a list of dictionaries that give information on each of
-the components pieces of the download. The dictionary's keys are
-`source`, `local_path`, `bytes` and `total_bytes`, and correspond to
-the like-named keys in the main event.
-
-Note that downloading events will not be issued for local models, and
-that downloading events occur _before_ the running event.
-
-##### `model_install_running`
-
-`model_install_running` is issued when all the required downloads have completed (if applicable) and the
-model probing, copying and registration process has now started.
-
-The payload will contain the key `source`.
-
-##### `model_install_completed`
-
-`model_install_completed` is issued once at the end of a successful
-installation. The payload will contain the keys `source`,
-`total_bytes` and `key`, where `key` is the ID under which the model
-has been registered.
-
-##### `model_install_error`
-
-`model_install_error` is emitted if the installation process fails for
-some reason. The payload will contain the keys `source`, `error_type`
-and `error`. `error_type` is a short message indicating the nature of
-the error, and `error` is the long traceback to help debug the
-problem.
-
-##### `model_install_cancelled`
-
-`model_install_cancelled` is issued if the model installation is
-cancelled, or if one or more of its files' downloads are
-cancelled. The payload will contain `source`.
-
-##### Following the model status
-
-You may poll the `ModelInstallJob` object returned by `import_model()`
-to ascertain the state of the install. The job status can be read from
-the job's `status` attribute, an `InstallStatus` enum which has the
-enumerated values `WAITING`, `DOWNLOADING`, `RUNNING`, `COMPLETED`,
-`ERROR` and `CANCELLED`.
-
-For convenience, install jobs also provided the following boolean
-properties: `waiting`, `downloading`, `running`, `complete`, `errored`
-and `cancelled`, as well as `in_terminal_state`. The last will return
-True if the job is in the complete, errored or cancelled states.
-
-#### Model configuration and probing
-
-The install service uses the `invokeai.backend.model_manager.probe`
-module during import to determine the model's type, base type, and
-other configuration parameters. Among other things, it assigns a
-default name and description for the model based on probed
-fields.
-
-When downloading remote models is implemented, additional
-configuration information, such as list of trigger terms, will be
-retrieved from the HuggingFace and Civitai model repositories.
-
-The probed values can be overriden by providing a dictionary in the
-optional `config` argument passed to `import_model()`. You may provide
-overriding values for any of the model's configuration
-attributes. Here is an example of setting the
-`SchedulerPredictionType` and `name` for an sd-2 model:
-
-```
-install_job = installer.import_model(
- source=HFModelSource(repo_id='stabilityai/stable-diffusion-2-1',variant='fp32'),
- config=dict(
- prediction_type=SchedulerPredictionType('v_prediction')
- name='stable diffusion 2 base model',
- )
- )
-```
-
-### Other installer methods
-
-This section describes additional methods provided by the installer class.
-
-#### jobs = installer.wait_for_installs([timeout])
-
-Block until all pending installs are completed or errored and then
-returns a list of completed jobs. The optional `timeout` argument will
-return from the call if jobs aren't completed in the specified
-time. An argument of 0 (the default) will block indefinitely.
-
-#### jobs = installer.wait_for_job(job, [timeout])
-
-Like `wait_for_installs()`, but block until a specific job has
-completed or errored, and then return the job. The optional `timeout`
-argument will return from the call if the job doesn't complete in the
-specified time. An argument of 0 (the default) will block
-indefinitely.
-
-#### jobs = installer.list_jobs()
-
-Return a list of all active and complete `ModelInstallJobs`.
-
-#### jobs = installer.get_job_by_source(source)
-
-Return a list of `ModelInstallJob` corresponding to the indicated
-model source.
-
-#### jobs = installer.get_job_by_id(id)
-
-Return a list of `ModelInstallJob` corresponding to the indicated
-model id.
-
-#### jobs = installer.cancel_job(job)
-
-Cancel the indicated job.
-
-#### installer.prune_jobs
-
-Remove jobs that are in a terminal state (i.e. complete, errored or
-cancelled) from the job list returned by `list_jobs()` and
-`get_job()`.
-
-#### installer.app_config, installer.record_store, installer.event_bus
-
-Properties that provide access to the installer's `InvokeAIAppConfig`,
-`ModelRecordServiceBase` and `EventServiceBase` objects.
-
-#### key = installer.register_path(model_path, config), key = installer.install_path(model_path, config)
-
-These methods bypass the download queue and directly register or
-install the model at the indicated path, returning the unique ID for
-the installed model.
-
-Both methods accept a Path object corresponding to a checkpoint or
-diffusers folder, and an optional dict of config attributes to use to
-override the values derived from model probing.
-
-The difference between `register_path()` and `install_path()` is that
-the former creates a model configuration record without changing the
-location of the model in the filesystem. The latter makes a copy of
-the model inside the InvokeAI models directory before registering
-it.
-
-#### installer.unregister(key)
-
-This will remove the model config record for the model at key, and is
-equivalent to `installer.record_store.del_model(key)`
-
-#### installer.delete(key)
-
-This is similar to `unregister()` but has the additional effect of
-conditionally deleting the underlying model file(s) if they reside
-within the InvokeAI models directory
-
-#### installer.unconditionally_delete(key)
-
-This method is similar to `unregister()`, but also unconditionally
-deletes the corresponding model weights file(s), regardless of whether
-they are inside or outside the InvokeAI models hierarchy.
-
-#### path = installer.download_and_cache(remote_source, [access_token], [timeout])
-
-This utility routine will download the model file located at source,
-cache it, and return the path to the cached file. It does not attempt
-to determine the model type, probe its configuration values, or
-register it with the models database.
-
-You may provide an access token if the remote source requires
-authorization. The call will block indefinitely until the file is
-completely downloaded, cancelled or raises an error of some sort. If
-you provide a timeout (in seconds), the call will raise a
-`TimeoutError` exception if the download hasn't completed in the
-specified period.
-
-You may use this mechanism to request any type of file, not just a
-model. The file will be stored in a subdirectory of
-`INVOKEAI_ROOT/models/.cache`. If the requested file is found in the
-cache, its path will be returned without redownloading it.
-
-Be aware that the models cache is cleared of infrequently-used files
-and directories at regular intervals when the size of the cache
-exceeds the value specified in Invoke's `convert_cache` configuration
-variable.
-
-#### installer.start(invoker)
-
-The `start` method is called by the API intialization routines when
-the API starts up. Its effect is to call `sync_to_config()` to
-synchronize the model record store database with what's currently on
-disk.
-
-***
-
-## Get on line: The Download Queue
-
-InvokeAI can download arbitrary files using a multithreaded background
-download queue. Internally, the download queue is used for installing
-models located at remote locations. The queue is implemented by the
-`DownloadQueueService` defined in
-`invokeai.app.services.download_manager`. However, most of the
-implementation is spread out among several files in
-`invokeai/backend/model_manager/download/*`
-
-A default download queue is located in
-`ApiDependencies.invoker.services.download_queue`. However, you can
-create additional instances if you need to isolate your queue from the
-main one.
-
-### A job for every task
-
-The queue operates on a series of download job objects. These objects
-specify the source and destination of the download, and keep track of
-the progress of the download. Jobs come in a variety of shapes and
-colors as they are progressively specialized for particular download
-task.
-
-The basic job is the `DownloadJobBase`, a pydantic object with the
-following fields:
-
-| **Field** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| `id` | int | | Job ID, an integer >= 0 |
-| `priority` | int | 10 | Job priority. Lower priorities run before higher priorities |
-| `source` | str | | Where to download from (specialized types used in subclasses)|
-| `destination` | Path | | Where to download to |
-| `status` | DownloadJobStatus| Idle | Job's status (see below) |
-| `event_handlers` | List[DownloadEventHandler]| | Event handlers (see below) |
-| `job_started` | float | | Timestamp for when the job started running |
-| `job_ended` | float | | Timestamp for when the job completed or errored out |
-| `job_sequence` | int | | A counter that is incremented each time a model is dequeued |
-| `error` | Exception | | A copy of the Exception that caused an error during download |
-
-When you create a job, you can assign it a `priority`. If multiple
-jobs are queued, the job with the lowest priority runs first. (Don't
-blame me! The Unix developers came up with this convention.)
-
-Every job has a `source` and a `destination`. `source` is a string in
-the base class, but subclassses redefine it more specifically.
-
-The `destination` must be the Path to a file or directory on the local
-filesystem. If the Path points to a new or existing file, then the
-source will be stored under that filename. If the Path ponts to an
-existing directory, then the downloaded file will be stored inside the
-directory, usually using the name assigned to it at the remote site in
-the `content-disposition` http field.
-
-When the job is submitted, it is assigned a numeric `id`. The id can
-then be used to control the job, such as starting, stopping and
-cancelling its download.
-
-The `status` field is updated by the queue to indicate where the job
-is in its lifecycle. Values are defined in the string enum
-`DownloadJobStatus`, a symbol available from
-`invokeai.app.services.download_manager`. Possible values are:
-
-| **Value** | **String Value** | **Description** |
-|--------------|---------------------|-------------------|
-| `IDLE` | idle | Job created, but not submitted to the queue |
-| `ENQUEUED` | enqueued | Job is patiently waiting on the queue |
-| `RUNNING` | running | Job is running! |
-| `PAUSED` | paused | Job was paused and can be restarted |
-| `COMPLETED` | completed | Job has finished its work without an error |
-| `ERROR` | error | Job encountered an error and will not run again|
-| `CANCELLED` | cancelled | Job was cancelled and will not run (again) |
-
-`job_started`, `job_ended` and `job_sequence` indicate when the job
-was started (using a python timestamp), when it completed, and the
-order in which it was taken off the queue. These are mostly used for
-debugging and performance testing.
-
-In case of an error, the Exception that caused the error will be
-placed in the `error` field, and the job's status will be set to
-`DownloadJobStatus.ERROR`.
-
-After an error occurs, any partially downloaded files will be deleted
-from disk, unless `preserve_partial_downloads` was set to True at job
-creation time (or set to True any time before the error
-occurred). Note that since all InvokeAI model install operations
-involve downloading files to a temporary directory that has a limited
-lifetime, this flag is not used by the model installer.
-
-There are a series of subclasses of `DownloadJobBase` that provide
-support for specific types of downloads. These are:
-
-#### DownloadJobPath
-
-This subclass redefines `source` to be a filesystem Path. It is used
-to move a file or directory from the `source` to the `destination`
-paths in the background using a uniform event-based infrastructure.
-
-#### DownloadJobRemoteSource
-
-This subclass adds the following fields to the job:
-
-| **Field** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| `bytes` | int | 0 | bytes downloaded so far |
-| `total_bytes` | int | 0 | total size to download |
-| `access_token` | Any | None | an authorization token to present to the remote source |
-
-The job will start out with 0/0 in its bytes/total_bytes fields. Once
-it starts running, `total_bytes` will be populated from information
-provided in the HTTP download header (if available), and the number of
-bytes downloaded so far will be progressively incremented.
-
-#### DownloadJobURL
-
-This is a subclass of `DownloadJobBase`. It redefines `source` to be a
-Pydantic `AnyHttpUrl` object, which enforces URL validation checking
-on the field.
-
-Note that the installer service defines an additional subclass of
-`DownloadJobRemoteSource` that accepts HuggingFace repo_ids in
-addition to URLs. This is discussed later in this document.
-
-### Event handlers
-
-While a job is being downloaded, the queue will emit events at
-periodic intervals. A typical series of events during a successful
-download session will look like this:
-
-* enqueued
-* running
-* running
-* running
-* completed
-
-There will be a single enqueued event, followed by one or more running
-events, and finally one `completed`, `error` or `cancelled`
-events.
-
-It is possible for a caller to pause download temporarily, in which
-case the events may look something like this:
-
-* enqueued
-* running
-* running
-* paused
-* running
-* completed
-
-The download queue logs when downloads start and end (unless `quiet`
-is set to True at initialization time) but doesn't log any progress
-events. You will probably want to be alerted to events during the
-download job and provide more user feedback. In order to intercept and
-respond to events you may install a series of one or more event
-handlers in the job. Whenever the job's status changes, the chain of
-event handlers is traversed and executed in the same thread that the
-download job is running in.
-
-Event handlers have the signature `Callable[["DownloadJobBase"],
-None]`, i.e.
-
-```
-def handler(job: DownloadJobBase):
- pass
-```
-
-A typical handler will examine `job.status` and decide if there's
-something to be done. This can include cancelling or erroring the job,
-but more typically is used to report on the job status to the user
-interface or to perform certain actions on successful completion of
-the job.
-
-Event handlers can be attached to a job at creation time. In addition,
-you can create a series of default handlers that are attached to the
-queue object itself. These handlers will be executed for each job
-after the job's own handlers (if any) have run.
-
-During a download, running events are issued every time roughly 1% of
-the file is transferred. This is to provide just enough granularity to
-update a tqdm progress bar smoothly.
-
-Handlers can be added to a job after the fact using the job's
-`add_event_handler` method:
-
-```
-job.add_event_handler(my_handler)
-```
-
-All handlers can be cleared using the job's `clear_event_handlers()`
-method. Note that it might be a good idea to pause the job before
-altering its handlers.
-
-### Creating a download queue object
-
-The `DownloadQueueService` constructor takes the following arguments:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|----------------|-----------------|---------------|-----------------|
-| `event_handlers` | List[DownloadEventHandler] | [] | Event handlers |
-| `max_parallel_dl` | int | 5 | Maximum number of simultaneous downloads allowed |
-| `requests_session` | requests.sessions.Session | None | An alternative requests Session object to use for the download |
-| `quiet` | bool | False| Do work quietly without issuing log messages |
-
-A typical initialization sequence will look like:
-
-```
-from invokeai.app.services.download_manager import DownloadQueueService
-
-def log_download_event(job: DownloadJobBase):
- logger.info(f'job={job.id}: status={job.status}')
-
-queue = DownloadQueueService(
- event_handlers=[log_download_event]
- )
-```
-
-Event handlers can be provided to the queue at initialization time as
-shown in the example. These will be automatically appended to the
-handler list for any job that is submitted to this queue.
-
-`max_parallel_dl` sets the number of simultaneous active downloads
-that are allowed. The default of five has not been benchmarked in any
-way, but seems to give acceptable performance.
-
-`requests_session` can be used to provide a `requests` module Session
-object that will be used to stream remote URLs to disk. This facility
-was added for use in the module's unit tests to simulate a remote web
-server, but may be useful in other contexts.
-
-`quiet` will prevent the queue from issuing any log messages at the
-INFO or higher levels.
-
-### Submitting a download job
-
-You can submit a download job to the queue either by creating the job
-manually and passing it to the queue's `submit_download_job()` method,
-or using the `create_download_job()` method, which will do the same
-thing on your behalf.
-
-To use the former method, follow this example:
-
-```
-job = DownloadJobRemoteSource(
- source='http://www.civitai.com/models/13456',
- destination='/tmp/models/',
- event_handlers=[my_handler1, my_handler2], # if desired
- )
-queue.submit_download_job(job, start=True)
-```
-
-`submit_download_job()` takes just two arguments: the job to submit,
-and a flag indicating whether to immediately start the job (defaulting
-to True). If you choose not to start the job immediately, you can
-start it later by calling the queue's `start_job()` or
-`start_all_jobs()` methods, which are described later.
-
-To have the queue create the job for you, follow this example instead:
-
-```
-job = queue.create_download_job(
- source='http://www.civitai.com/models/13456',
- destdir='/tmp/models/',
- filename='my_model.safetensors',
- event_handlers=[my_handler1, my_handler2], # if desired
- start=True,
- )
-```
-
-The `filename` argument forces the downloader to use the specified
-name for the file rather than the name provided by the remote source,
-and is equivalent to manually specifying a destination of
-`/tmp/models/my_model.safetensors' in the submitted job.
-
-Here is the full list of arguments that can be provided to
-`create_download_job()`:
-
-| **Argument** | **Type** | **Default** | **Description** |
-|------------------|------------------------------|-------------|-------------------------------------------|
-| `source` | Union[str, Path, AnyHttpUrl] | | Download remote or local source |
-| `destdir` | Path | | Destination directory for downloaded file |
-| `filename` | Path | None | Filename for downloaded file |
-| `start` | bool | True | Enqueue the job immediately |
-| `priority` | int | 10 | Starting priority for this job |
-| `access_token` | str | None | Authorization token for this resource |
-| `event_handlers` | List[DownloadEventHandler] | [] | Event handlers for this job |
-
-Internally, `create_download_job()` has a little bit of internal logic
-that looks at the type of the source and selects the right subclass of
-`DownloadJobBase` to create and enqueue.
-
-**TODO**: move this logic into its own method for overriding in
-subclasses.
-
-### Job control
-
-Prior to completion, jobs can be controlled with a series of queue
-method calls. Do not attempt to modify jobs by directly writing to
-their fields, as this is likely to lead to unexpected results.
-
-Any method that accepts a job argument may raise an
-`UnknownJobIDException` if the job has not yet been submitted to the
-queue or was not created by this queue.
-
-#### queue.join()
-
-This method will block until all the active jobs in the queue have
-reached a terminal state (completed, errored or cancelled).
-
-#### queue.wait_for_job(job, [timeout])
-
-This method will block until the indicated job has reached a terminal
-state (completed, errored or cancelled). If the optional timeout is
-provided, the call will block for at most timeout seconds, and raise a
-TimeoutError otherwise.
-
-#### jobs = queue.list_jobs()
-
-This will return a list of all jobs, including ones that have not yet
-been enqueued and those that have completed or errored out.
-
-#### job = queue.id_to_job(int)
-
-This method allows you to recover a submitted job using its ID.
-
-#### queue.prune_jobs()
-
-Remove completed and errored jobs from the job list.
-
-#### queue.start_job(job)
-
-If the job was submitted with `start=False`, then it can be started
-using this method.
-
-#### queue.pause_job(job)
-
-This will temporarily pause the job, if possible. It can later be
-restarted and pick up where it left off using `queue.start_job()`.
-
-#### queue.cancel_job(job)
-
-This will cancel the job if possible and clean up temporary files and
-other resources that it might have been using.
-
-#### queue.start_all_jobs(), queue.pause_all_jobs(), queue.cancel_all_jobs()
-
-This will start/pause/cancel all jobs that have been submitted to the
-queue and have not yet reached a terminal state.
-
-***
-
-## This Meta be Good: Model Metadata Storage
-
-The modules found under `invokeai.backend.model_manager.metadata`
-provide a straightforward API for fetching model metadatda from online
-repositories. Currently only HuggingFace is supported. However, the
-modules are easily extended for additional repos, provided that they
-have defined APIs for metadata access.
-
-Metadata comprises any descriptive information that is not essential
-for getting the model to run. For example "author" is metadata, while
-"type", "base" and "format" are not. The latter fields are part of the
-model's config, as defined in `invokeai.backend.model_manager.config`.
-
-### Example Usage
-
-```
-from invokeai.backend.model_manager.metadata import (
- AnyModelRepoMetadata,
-)
-# to access the initialized sql database
-from invokeai.app.api.dependencies import ApiDependencies
-
-hf = HuggingFaceMetadataFetch()
-
-# fetch the metadata
-model_metadata = hf.from_id("")
-
-assert isinstance(model_metadata, HuggingFaceMetadata)
-```
-
-### Structure of the Metadata objects
-
-There is a short class hierarchy of Metadata objects, all of which
-descend from the Pydantic `BaseModel`.
-
-#### `ModelMetadataBase`
-
-This is the common base class for metadata:
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `name` | str | Repository's name for the model |
-| `author` | str | Model's author |
-| `tags` | Set[str] | Model tags |
-
-Note that the model config record also has a `name` field. It is
-intended that the config record version be locally customizable, while
-the metadata version is read-only. However, enforcing this is expected
-to be part of the business logic.
-
-Descendents of the base add additional fields.
-
-#### `HuggingFaceMetadata`
-
-This descends from `ModelMetadataBase` and adds the following fields:
-
-| **Field Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `type` | Literal["huggingface"] | Used for the discriminated union of metadata classes|
-| `id` | str | HuggingFace repo_id |
-| `tag_dict` | Dict[str, Any] | A dictionary of tag/value pairs provided in addition to `tags` |
-| `last_modified`| datetime | Date of last commit of this model to the repo |
-| `files` | List[Path] | List of the files in the model repo |
-
-#### `AnyModelRepoMetadata`
-
-This is a discriminated Union of `HuggingFaceMetadata`.
-
-### Fetching Metadata from Online Repos
-
-The `HuggingFaceMetadataFetch` class will
-retrieve metadata from its corresponding repository and return
-`AnyModelRepoMetadata` objects. Their base class
-`ModelMetadataFetchBase` is an abstract class that defines two
-methods: `from_url()` and `from_id()`. The former accepts the type of
-model URLs that the user will try to cut and paste into the model
-import form. The latter accepts a string ID in the format recognized
-by the repository of choice. Both methods return an
-`AnyModelRepoMetadata`.
-
-The base class also has a class method `from_json()` which will take
-the JSON representation of a `ModelMetadata` object, validate it, and
-return the corresponding `AnyModelRepoMetadata` object.
-
-When initializing one of the metadata fetching classes, you may
-provide a `requests.Session` argument. This allows you to customize
-the low-level HTTP fetch requests and is used, for instance, in the
-testing suite to avoid hitting the internet.
-
-The HuggingFace fetcher subclass add additional repo-specific fetching methods:
-
-#### HuggingFaceMetadataFetch
-
-This overrides its base class `from_json()` method to return a
-`HuggingFaceMetadata` object directly.
-
-### Metadata Storage
-
-The `ModelConfigBase` stores this response in the `source_api_response` field
-as a JSON blob.
-
-***
-
-## The Lowdown on the ModelLoadService
-
-The `ModelLoadService` is responsible for loading a named model into
-memory so that it can be used for inference. Despite the fact that it
-does a lot under the covers, it is very straightforward to use.
-
-An application-wide model loader is created at API initialization time
-and stored in
-`ApiDependencies.invoker.services.model_loader`. However, you can
-create alternative instances if you wish.
-
-### Creating a ModelLoadService object
-
-The class is defined in
-`invokeai.app.services.model_load`. It is initialized with
-an InvokeAIAppConfig object, from which it gets configuration
-information such as the user's desired GPU and precision, and with a
-previously-created `ModelRecordServiceBase` object, from which it
-loads the requested model's configuration information.
-
-Here is a typical initialization pattern:
-
-```
-from invokeai.app.services.config import InvokeAIAppConfig
-from invokeai.app.services.model_load import ModelLoadService, ModelLoaderRegistry
-
-config = InvokeAIAppConfig.get_config()
-ram_cache = ModelCache(
- max_cache_size=config.ram_cache_size, max_vram_cache_size=config.vram_cache_size, logger=logger
-)
-convert_cache = ModelConvertCache(
- cache_path=config.models_convert_cache_path, max_size=config.convert_cache_size
-)
-loader = ModelLoadService(
- app_config=config,
- ram_cache=ram_cache,
- convert_cache=convert_cache,
- registry=ModelLoaderRegistry
-)
-```
-
-### load_model(model_config, [submodel_type], [context]) -> LoadedModel
-
-The `load_model()` method takes an `AnyModelConfig` returned by
-`ModelRecordService.get_model()` and returns the corresponding loaded
-model. It loads the model into memory, gets the model ready for use,
-and returns a `LoadedModel` object.
-
-The optional second argument, `subtype` is a `SubModelType` string
-enum, such as "vae". It is mandatory when used with a main model, and
-is used to select which part of the main model to load.
-
-The optional third argument, `context` can be provided by
-an invocation to trigger model load event reporting. See below for
-details.
-
-The returned `LoadedModel` object contains a copy of the configuration
-record returned by the model record `get_model()` method, as well as
-the in-memory loaded model:
-
-| **Attribute Name** | **Type** | **Description** |
-|----------------|-----------------|------------------|
-| `config` | AnyModelConfig | A copy of the model's configuration record for retrieving base type, etc. |
-| `model` | AnyModel | The instantiated model (details below) |
-| `locker` | ModelLockerBase | A context manager that mediates the movement of the model into VRAM |
-
-### get_model_by_key(key, [submodel]) -> LoadedModel
-
-The `get_model_by_key()` method will retrieve the model using its
-unique database key. For example:
-
-loaded_model = loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
-
-`get_model_by_key()` may raise any of the following exceptions:
-
-* `UnknownModelException` -- key not in database
-* `ModelNotFoundException` -- key in database but model not found at path
-* `NotImplementedException` -- the loader doesn't know how to load this type of model
-
-### Using the Loaded Model in Inference
-
-`LoadedModel` acts as a context manager. The context loads the model
-into the execution device (e.g. VRAM on CUDA systems), locks the model
-in the execution device for the duration of the context, and returns
-the model. Use it like this:
-
-```
-loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
-with loaded_model as vae:
- image = vae.decode(latents)[0]
-```
-
-The object returned by the LoadedModel context manager is an
-`AnyModel`, which is a Union of `ModelMixin`, `torch.nn.Module`,
-`IAIOnnxRuntimeModel`, `IPAdapter`, `IPAdapterPlus`, and
-`EmbeddingModelRaw`. `ModelMixin` is the base class of all diffusers
-models, `EmbeddingModelRaw` is used for LoRA and TextualInversion
-models. The others are obvious.
-
-In addition, you may call `LoadedModel.model_on_device()`, a context
-manager that returns a tuple of the model's state dict in CPU and the
-model itself in VRAM. It is used to optimize the LoRA patching and
-unpatching process:
-
-```
-loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
-with loaded_model.model_on_device() as (state_dict, vae):
- image = vae.decode(latents)[0]
-```
-
-Since not all models have state dicts, the `state_dict` return value
-can be None.
-
-
-### Emitting model loading events
-
-When the `context` argument is passed to `load_model_*()`, it will
-retrieve the invocation event bus from the passed `InvocationContext`
-object to emit events on the invocation bus. The two events are
-"model_load_started" and "model_load_completed". Both carry the
-following payload:
-
-```
-payload=dict(
- queue_id=queue_id,
- queue_item_id=queue_item_id,
- queue_batch_id=queue_batch_id,
- graph_execution_state_id=graph_execution_state_id,
- model_key=model_key,
- submodel_type=submodel,
- hash=model_info.hash,
- location=str(model_info.location),
- precision=str(model_info.precision),
-)
-```
-
-### Adding Model Loaders
-
-Model loaders are small classes that inherit from the `ModelLoader`
-base class. They typically implement one method `_load_model()` whose
-signature is:
-
-```
-def _load_model(
- self,
- model_path: Path,
- model_variant: Optional[ModelRepoVariant] = None,
- submodel_type: Optional[SubModelType] = None,
-) -> AnyModel:
-```
-
-`_load_model()` will be passed the path to the model on disk, an
-optional repository variant (used by the diffusers loaders to select,
-e.g. the `fp16` variant, and an optional submodel_type for main and
-onnx models.
-
-To install a new loader, place it in
-`invokeai/backend/model_manager/load/model_loaders`. Inherit from
-`ModelLoader` and use the `@ModelLoaderRegistry.register()` decorator to
-indicate what type of models the loader can handle.
-
-Here is a complete example from `generic_diffusers.py`, which is able
-to load several different diffusers types:
-
-```
-from pathlib import Path
-from typing import Optional
-
-from invokeai.backend.model_manager import (
- AnyModel,
- BaseModelType,
- ModelFormat,
- ModelRepoVariant,
- ModelType,
- SubModelType,
-)
-from .. import ModelLoader, ModelLoaderRegistry
-
-
-@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.CLIPVision, format=ModelFormat.Diffusers)
-@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.T2IAdapter, format=ModelFormat.Diffusers)
-class GenericDiffusersLoader(ModelLoader):
- """Class to load simple diffusers models."""
-
- def _load_model(
- self,
- model_path: Path,
- model_variant: Optional[ModelRepoVariant] = None,
- submodel_type: Optional[SubModelType] = None,
- ) -> AnyModel:
- model_class = self._get_hf_load_class(model_path)
- if submodel_type is not None:
- raise Exception(f"There are no submodels in models of type {model_class}")
- variant = model_variant.value if model_variant else None
- result: AnyModel = model_class.from_pretrained(model_path, torch_dtype=self._torch_dtype, variant=variant) # type: ignore
- return result
-```
-
-Note that a loader can register itself to handle several different
-model types. An exception will be raised if more than one loader tries
-to register the same model type.
-
-#### Conversion
-
-Some models require conversion to diffusers format before they can be
-loaded. These loaders should override two additional methods:
-
-```
-_needs_conversion(self, config: AnyModelConfig, model_path: Path, dest_path: Path) -> bool
-_convert_model(self, config: AnyModelConfig, model_path: Path, output_path: Path) -> Path:
-```
-
-The first method accepts the model configuration, the path to where
-the unmodified model is currently installed, and a proposed
-destination for the converted model. This method returns True if the
-model needs to be converted. It typically does this by comparing the
-last modification time of the original model file to the modification
-time of the converted model. In some cases you will also want to check
-the modification date of the configuration record, in the event that
-the user has changed something like the scheduler prediction type that
-will require the model to be re-converted. See `controlnet.py` for an
-example of this logic.
-
-The second method accepts the model configuration, the path to the
-original model on disk, and the desired output path for the converted
-model. It does whatever it needs to do to get the model into diffusers
-format, and returns the Path of the resulting model. (The path should
-ordinarily be the same as `output_path`.)
-
-## The ModelManagerService object
-
-For convenience, the API provides a `ModelManagerService` object which
-gives a single point of access to the major model manager
-services. This object is created at initialization time and can be
-found in the global `ApiDependencies.invoker.services.model_manager`
-object, or in `context.services.model_manager` from within an
-invocation.
-
-In the examples below, we have retrieved the manager using:
-
-```
-mm = ApiDependencies.invoker.services.model_manager
-```
-
-The following properties and methods will be available:
-
-### mm.store
-
-This retrieves the `ModelRecordService` associated with the
-manager. Example:
-
-```
-configs = mm.store.get_model_by_attr(name='stable-diffusion-v1-5')
-```
-
-### mm.install
-
-This retrieves the `ModelInstallService` associated with the manager.
-Example:
-
-```
-job = mm.install.heuristic_import(`https://civitai.com/models/58390/detail-tweaker-lora-lora`)
-```
-
-### mm.load
-
-This retrieves the `ModelLoaderService` associated with the manager. Example:
-
-```
-configs = mm.store.get_model_by_attr(name='stable-diffusion-v1-5')
-assert len(configs) > 0
-
-loaded_model = mm.load.load_model(configs[0])
-```
-
-The model manager also offers a few convenience shortcuts for loading
-models:
-
-### mm.load_model_by_config(model_config, [submodel], [context]) -> LoadedModel
-
-Same as `mm.load.load_model()`.
-
-### mm.load_model_by_attr(model_name, base_model, model_type, [submodel], [context]) -> LoadedModel
-
-This accepts the combination of the model's name, type and base, which
-it passes to the model record config store for retrieval. If a unique
-model config is found, this method returns a `LoadedModel`. It can
-raise the following exceptions:
-
-```
-UnknownModelException -- model with these attributes not known
-NotImplementedException -- the loader doesn't know how to load this type of model
-ValueError -- more than one model matches this combination of base/type/name
-```
-
-### mm.load_model_by_key(key, [submodel], [context]) -> LoadedModel
-
-This method takes a model key, looks it up using the
-`ModelRecordServiceBase` object in `mm.store`, and passes the returned
-model configuration to `load_model_by_config()`. It may raise a
-`NotImplementedException`.
-
-## Invocation Context Model Manager API
-
-Within invocations, the following methods are available from the
-`InvocationContext` object:
-
-### context.download_and_cache_model(source) -> Path
-
-This method accepts a `source` of a remote model, downloads and caches
-it locally, and then returns a Path to the local model. The source can
-be a direct download URL or a HuggingFace repo_id.
-
-In the case of HuggingFace repo_id, the following variants are
-recognized:
-
-* stabilityai/stable-diffusion-v4 -- default model
-* stabilityai/stable-diffusion-v4:fp16 -- fp16 variant
-* stabilityai/stable-diffusion-v4:fp16:vae -- the fp16 vae subfolder
-* stabilityai/stable-diffusion-v4:onnx:vae -- the onnx variant vae subfolder
-
-You can also point at an arbitrary individual file within a repo_id
-directory using this syntax:
-
-* stabilityai/stable-diffusion-v4::/checkpoints/sd4.safetensors
-
-### context.load_local_model(model_path, [loader]) -> LoadedModel
-
-This method loads a local model from the indicated path, returning a
-`LoadedModel`. The optional loader is a Callable that accepts a Path
-to the object, and returns a `AnyModel` object. If no loader is
-provided, then the method will use `torch.load()` for a .ckpt or .bin
-checkpoint file, `safetensors.torch.load_file()` for a safetensors
-checkpoint file, or `cls.from_pretrained()` for a directory that looks
-like a diffusers directory.
-
-### context.load_remote_model(source, [loader]) -> LoadedModel
-
-This method accepts a `source` of a remote model, downloads and caches
-it locally, loads it, and returns a `LoadedModel`. The source can be a
-direct download URL or a HuggingFace repo_id.
-
-In the case of HuggingFace repo_id, the following variants are
-recognized:
-
-* stabilityai/stable-diffusion-v4 -- default model
-* stabilityai/stable-diffusion-v4:fp16 -- fp16 variant
-* stabilityai/stable-diffusion-v4:fp16:vae -- the fp16 vae subfolder
-* stabilityai/stable-diffusion-v4:onnx:vae -- the onnx variant vae subfolder
-
-You can also point at an arbitrary individual file within a repo_id
-directory using this syntax:
-
-* stabilityai/stable-diffusion-v4::/checkpoints/sd4.safetensors
-
-
-
diff --git a/docs/contributing/TESTS.md b/docs/contributing/TESTS.md
deleted file mode 100644
index 8d823bb4e97..00000000000
--- a/docs/contributing/TESTS.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# InvokeAI Backend Tests
-
-We use `pytest` to run the backend python tests. (See [pyproject.toml](/pyproject.toml) for the default `pytest` options.)
-
-## Fast vs. Slow
-All tests are categorized as either 'fast' (no test annotation) or 'slow' (annotated with the `@pytest.mark.slow` decorator).
-
-'Fast' tests are run to validate every PR, and are fast enough that they can be run routinely during development.
-
-'Slow' tests are currently only run manually on an ad-hoc basis. In the future, they may be automated to run nightly. Most developers are only expected to run the 'slow' tests that directly relate to the feature(s) that they are working on.
-
-As a rule of thumb, tests should be marked as 'slow' if there is a chance that they take >1s (e.g. on a CPU-only machine with slow internet connection). Common examples of slow tests are tests that depend on downloading a model, or running model inference.
-
-## Running Tests
-
-Below are some common test commands:
-```bash
-# Run the fast tests. (This implicitly uses the configured default option: `-m "not slow"`.)
-pytest tests/
-
-# Equivalent command to run the fast tests.
-pytest tests/ -m "not slow"
-
-# Run the slow tests.
-pytest tests/ -m "slow"
-
-# Run the slow tests from a specific file.
-pytest tests/path/to/slow_test.py -m "slow"
-
-# Run all tests (fast and slow).
-pytest tests -m ""
-```
-
-## Test Organization
-
-All backend tests are in the [`tests/`](/tests/) directory. This directory mirrors the organization of the `invokeai/` directory. For example, tests for `invokeai/model_management/model_manager.py` would be found in `tests/model_management/test_model_manager.py`.
-
-TODO: The above statement is aspirational. A re-organization of legacy tests is required to make it true.
-
-## Tests that depend on models
-
-There are a few things to keep in mind when adding tests that depend on models.
-
-1. If a required model is not already present, it should automatically be downloaded as part of the test setup.
-2. If a model is already downloaded, it should not be re-downloaded unnecessarily.
-3. Take reasonable care to keep the total number of models required for the tests low. Whenever possible, re-use models that are already required for other tests. If you are adding a new model, consider including a comment to explain why it is required/unique.
-
-There are several utilities to help with model setup for tests. Here is a sample test that depends on a model:
-```python
-import pytest
-import torch
-
-from invokeai.backend.model_management.models.base import BaseModelType, ModelType
-from invokeai.backend.util.test_utils import install_and_load_model
-
-@pytest.mark.slow
-def test_model(model_installer, torch_device):
- model_info = install_and_load_model(
- model_installer=model_installer,
- model_path_id_or_url="HF/dummy_model_id",
- model_name="dummy_model",
- base_model=BaseModelType.StableDiffusion1,
- model_type=ModelType.Dummy,
- )
-
- dummy_input = build_dummy_input(torch_device)
-
- with torch.no_grad(), model_info as model:
- model.to(torch_device, dtype=torch.float32)
- output = model(dummy_input)
-
- # Validate output...
-
-```
-
-## Test Coverage
-
-To review test coverage, append `--cov` to your pytest command:
-```bash
-pytest tests/ --cov
-```
-
-Test outcomes and coverage will be reported in the terminal. In addition, a more detailed report is created in both XML and HTML format in the `./coverage` folder. The HTML output is particularly helpful in identifying untested statements where coverage should be improved. The HTML report can be viewed by opening `./coverage/html/index.html`.
-
-??? info "HTML coverage report output"
-
- 
-
- 
diff --git a/docs/contributing/contribution_guides/development.md b/docs/contributing/contribution_guides/development.md
deleted file mode 100644
index 092d2acae30..00000000000
--- a/docs/contributing/contribution_guides/development.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# Development
-
-## **What do I need to know to help?**
-
-If you are looking to help to with a code contribution, InvokeAI uses several different technologies under the hood: Python (Pydantic, FastAPI, diffusers) and Typescript (React, Redux Toolkit, ChakraUI, Mantine, Konva). Familiarity with StableDiffusion and image generation concepts is helpful, but not essential.
-
-
-## **Get Started**
-
-To get started, take a look at our [new contributors checklist](newContributorChecklist.md)
-
-Once you're setup, for more information, you can review the documentation specific to your area of interest:
-
-* #### [InvokeAI Architecure](../ARCHITECTURE.md)
-* #### [Frontend Documentation](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web)
-* #### [Node Documentation](../INVOCATIONS.md)
-* #### [Local Development](../LOCAL_DEVELOPMENT.md)
-
-
-
-If you don't feel ready to make a code contribution yet, no problem! You can also help out in other ways, such as [documentation](documentation.md), [translation](translation.md) or helping support other users and triage issues as they're reported in GitHub.
-
-There are two paths to making a development contribution:
-
-1. Choosing an open issue to address. Open issues can be found in the [Issues](https://github.com/invoke-ai/InvokeAI/issues?q=is%3Aissue+is%3Aopen) section of the InvokeAI repository. These are tagged by the issue type (bug, enhancement, etc.) along with the “good first issues” tag denoting if they are suitable for first time contributors.
- 1. Additional items can be found on our [roadmap](https://github.com/orgs/invoke-ai/projects/7). The roadmap is organized in terms of priority, and contains features of varying size and complexity. If there is an inflight item you’d like to help with, reach out to the contributor assigned to the item to see how you can help.
-2. Opening a new issue or feature to add. **Please make sure you have searched through existing issues before creating new ones.**
-
-*Regardless of what you choose, please post in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord before you start development in order to confirm that the issue or feature is aligned with the current direction of the project. We value our contributors time and effort and want to ensure that no one’s time is being misspent.*
-
-## Best Practices:
-* Keep your pull requests small. Smaller pull requests are more likely to be accepted and merged
-* Comments! Commenting your code helps reviewers easily understand your contribution
-* Use Python and Typescript’s typing systems, and consider using an editor with [LSP](https://microsoft.github.io/language-server-protocol/) support to streamline development
-* Make all communications public. This ensure knowledge is shared with the whole community
-
-## **Where can I go for help?**
-
-If you need help, you can ask questions in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord.
-
-For frontend related work, **@psychedelicious** is the best person to reach out to.
-
-For backend related work, please reach out to **@blessedcoolant**, **@lstein**, **@StAlKeR7779** or **@psychedelicious**.
-
-
-## **What does the Code of Conduct mean for me?**
-
-Our [Code of Conduct](../../CODE_OF_CONDUCT.md) means that you are responsible for treating everyone on the project with respect and courtesy regardless of their identity. If you are the victim of any inappropriate behavior or comments as described in our Code of Conduct, we are here for you and will do the best to ensure that the abuser is reprimanded appropriately, per our code.
-
diff --git a/docs/contributing/contribution_guides/documentation.md b/docs/contributing/contribution_guides/documentation.md
deleted file mode 100644
index 1b5b93f9ba9..00000000000
--- a/docs/contributing/contribution_guides/documentation.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Documentation
-
-Documentation is an important part of any open source project. It provides a clear and concise way to communicate how the software works, how to use it, and how to troubleshoot issues. Without proper documentation, it can be difficult for users to understand the purpose and functionality of the project.
-
-## Contributing
-
-All documentation is maintained in the InvokeAI GitHub repository. If you come across documentation that is out of date or incorrect, please submit a pull request with the necessary changes.
-
-When updating or creating documentation, please keep in mind InvokeAI is a tool for everyone, not just those who have familiarity with generative art.
-
-## Help & Questions
-
-Please ping @imic or @hipsterusername in the [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions.
\ No newline at end of file
diff --git a/docs/contributing/contribution_guides/newContributorChecklist.md b/docs/contributing/contribution_guides/newContributorChecklist.md
deleted file mode 100644
index 90725f99ab1..00000000000
--- a/docs/contributing/contribution_guides/newContributorChecklist.md
+++ /dev/null
@@ -1,68 +0,0 @@
-# New Contributor Guide
-
-If you're a new contributor to InvokeAI or Open Source Projects, this is the guide for you.
-
-## New Contributor Checklist
-- [x] Set up your local development environment & fork of InvokAI by following [the steps outlined here](../../installation/020_INSTALL_MANUAL.md#developer-install)
-- [x] Set up your local tooling with [this guide](InvokeAI/contributing/LOCAL_DEVELOPMENT/#developing-invokeai-in-vscode). Feel free to skip this step if you already have tooling you're comfortable with.
-- [x] Familiarize yourself with [Git](https://www.atlassian.com/git) & our project structure by reading through the [development documentation](development.md)
-- [x] Join the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord
-- [x] Choose an issue to work on! This can be achieved by asking in the #dev-chat channel, tackling a [good first issue](https://github.com/invoke-ai/InvokeAI/contribute) or finding an item on the [roadmap](https://github.com/orgs/invoke-ai/projects/7). If nothing in any of those places catches your eye, feel free to work on something of interest to you!
-- [x] Make your first Pull Request with the guide below
-- [x] Happy development! Don't be afraid to ask for help - we're happy to help you contribute!
-
-
-## How do I make a contribution?
-
-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).
-
-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.
-2. Fork the [InvokeAI](https://github.com/invoke-ai/InvokeAI) repository to your GitHub profile. This means that you will have a copy of the repository under **your-GitHub-username/InvokeAI**.
-3. Clone the repository to your local machine using:
-```bash
-git clone https://github.com/your-GitHub-username/InvokeAI.git
-```
-If you're unfamiliar with using Git through the commandline, [GitHub Desktop](https://desktop.github.com) is a easy-to-use alternative with a UI. You can do all the same steps listed here, but through the interface.
-4. Create a new branch for your fix using:
-```bash
-git checkout -b branch-name-here
-```
-5. Make the appropriate changes for the issue you are trying to address or the feature that you want to add.
-6. Add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index:
-```bash
-git add -A
-```
-7. Store the contents of the index with a descriptive message.
-```bash
-git commit -m "Insert a short message of the changes made here"
-```
-8. Push the changes to the remote repository using
-```bash
-git push origin branch-name-here
-```
-9. Submit a pull request to the **main** branch of the InvokeAI repository. If you're not sure how to, [follow this guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request)
-10. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #1234".
-11. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it!
-12. Wait for the pull request to be reviewed by other collaborators.
-13. Make changes to the pull request if the reviewer(s) recommend them.
-14. Celebrate your success after your pull request is merged!
-
-If you’d like to learn more about contributing to Open Source projects, here is a [Getting Started Guide](https://opensource.com/article/19/7/create-pull-request-github).
-
-
-## Best Practices:
-* Keep your pull requests small. Smaller pull requests are more likely to be accepted and merged
-* Comments! Commenting your code helps reviewers easily understand your contribution
-* Use Python and Typescript’s typing systems, and consider using an editor with [LSP](https://microsoft.github.io/language-server-protocol/) support to streamline development
-* Make all communications public. This ensure knowledge is shared with the whole community
-
-
-## **Where can I go for help?**
-
-If you need help, you can ask questions in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord.
-
-For frontend related work, **@pyschedelicious** is the best person to reach out to.
-
-For backend related work, please reach out to **@blessedcoolant**, **@lstein**, **@StAlKeR7779** or **@pyschedelicious**.
diff --git a/docs/contributing/contribution_guides/translation.md b/docs/contributing/contribution_guides/translation.md
deleted file mode 100644
index 669e4033467..00000000000
--- a/docs/contributing/contribution_guides/translation.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Translation
-
-InvokeAI uses [Weblate](https://weblate.org/) for translation. Weblate is a FOSS project providing a scalable translation service. Weblate automates the tedious parts of managing translation of a growing project, and the service is generously provided at no cost to FOSS projects like InvokeAI.
-
-## Contributing
-
-If you'd like to contribute by adding or updating a translation, please visit our [Weblate project](https://hosted.weblate.org/engage/invokeai/). You'll need to sign in with your GitHub account (a number of other accounts are supported, including Google).
-
-Once signed in, select a language and then the Web UI component. From here you can Browse and Translate strings from English to your chosen language. Zen mode offers a simpler translation experience.
-
-Your changes will be attributed to you in the automated PR process; you don't need to do anything else.
-
-## Help & Questions
-
-Please check Weblate's [documentation](https://docs.weblate.org/en/latest/index.html) or ping @Harvestor on [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions.
-
-## Thanks
-
-Thanks to the InvokeAI community for their efforts to translate the project!
\ No newline at end of file
diff --git a/docs/contributing/contribution_guides/tutorials.md b/docs/contributing/contribution_guides/tutorials.md
deleted file mode 100644
index 0d550e7023f..00000000000
--- a/docs/contributing/contribution_guides/tutorials.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Tutorials
-
-Tutorials help new & existing users expand their abilty to use InvokeAI to the full extent of our features and services.
-
-Currently, we have a set of tutorials available on our [YouTube channel](https://www.youtube.com/@invokeai), but as InvokeAI continues to evolve with new updates, we want to ensure that we are giving our users the resources they need to succeed.
-
-Tutorials can be in the form of videos or article walkthroughs on a subject of your choice. We recommend focusing tutorials on the key image generation methods, or on a specific component within one of the image generation methods.
-
-## Contributing
-
-Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI.
\ No newline at end of file
diff --git a/docs/contributing/frontend/OVERVIEW.md b/docs/contributing/frontend/OVERVIEW.md
deleted file mode 100644
index 486e75ea9f9..00000000000
--- a/docs/contributing/frontend/OVERVIEW.md
+++ /dev/null
@@ -1,133 +0,0 @@
-# Invoke UI
-
-Invoke's UI is made possible by many contributors and open-source libraries. Thank you!
-
-## Dev environment
-
-### Setup
-
-1. Install [node] and [pnpm].
-1. Run `pnpm i` to install all packages.
-
-#### Run in dev mode
-
-1. From `invokeai/frontend/web/`, run `pnpm dev`.
-1. From repo root, run `python scripts/invokeai-web.py`.
-1. Point your browser to the dev server address, e.g.
-
-### Package scripts
-
-- `dev`: run the frontend in dev mode, enabling hot reloading
-- `build`: run all checks (madge, eslint, prettier, tsc) and then build the frontend
-- `typegen`: generate types from the OpenAPI schema (see [Type generation])
-- `lint:dpdm`: check circular dependencies
-- `lint:eslint`: check code quality
-- `lint:prettier`: check code formatting
-- `lint:tsc`: check type issues
-- `lint:knip`: check for unused exports or objects (failures here are just suggestions, not hard fails)
-- `lint`: run all checks concurrently
-- `fix`: run `eslint` and `prettier`, fixing fixable issues
-
-### Type generation
-
-We use [openapi-typescript] to generate types from the app's OpenAPI schema.
-
-The generated types are committed to the repo in [schema.ts].
-
-```sh
-# from the repo root, start the server
-python scripts/invokeai-web.py
-# from invokeai/frontend/web/, run the script
-pnpm typegen
-```
-
-### Localization
-
-We use [i18next] for localization, but translation to languages other than English happens on our [Weblate] project.
-
-Only the English source strings should be changed on this repo.
-
-### VSCode
-
-#### Example debugger config
-
-```jsonc
-{
- "version": "0.2.0",
- "configurations": [
- {
- "type": "chrome",
- "request": "launch",
- "name": "Invoke UI",
- "url": "http://localhost:5173",
- "webRoot": "${workspaceFolder}/invokeai/frontend/web"
- }
- ]
-}
-```
-
-#### Remote dev
-
-We've noticed an intermittent timeout issue with the VSCode remote dev port forwarding.
-
-We suggest disabling the editor's port forwarding feature and doing it manually via SSH:
-
-```sh
-ssh -L 9090:localhost:9090 -L 5173:localhost:5173 user@host
-```
-
-## Contributing Guidelines
-
-Thanks for your interest in contributing to the Invoke Web UI!
-
-Please follow these guidelines when contributing.
-
-### Check in before investing your time
-
-Please check in before you invest your time on anything besides a trivial fix, in case it conflicts with ongoing work or isn't aligned with the vision for the app.
-
-If a feature request or issue doesn't already exist for the thing you want to work on, please create one.
-
-Ping `@psychedelicious` on [discord] in the `#frontend-dev` channel or in the feature request / issue you want to work on - we're happy to chat.
-
-### Code conventions
-
-- This is a fairly complex app with a deep component tree. Please use memoization (`useCallback`, `useMemo`, `memo`) with enthusiasm.
-- If you need to add some global, ephemeral state, please use [nanostores] if possible.
-- Be careful with your redux selectors. If they need to be parameterized, consider creating them inside a `useMemo`.
-- Feel free to use `lodash` (via `lodash-es`) to make the intent of your code clear.
-- Please add comments describing the "why", not the "how" (unless it is really arcane).
-
-### Commit format
-
-Please use the [conventional commits] spec for the web UI, with a scope of "ui":
-
-- `chore(ui): bump deps`
-- `chore(ui): lint`
-- `feat(ui): add some cool new feature`
-- `fix(ui): fix some bug`
-
-### Submitting a PR
-
-- Ensure your branch is tidy. Use an interactive rebase to clean up the commit history and reword the commit messages if they are not descriptive.
-- Run `pnpm lint`. Some issues are auto-fixable with `pnpm fix`.
-- Fill out the PR form when creating the PR.
- - It doesn't need to be super detailed, but a screenshot or video is nice if you changed something visually.
- - If a section isn't relevant, delete it. There are no UI tests at this time.
-
-## Other docs
-
-- [Workflows - Design and Implementation]
-- [State Management]
-
-[node]: https://nodejs.org/en/download/
-[pnpm]: https://github.com/pnpm/pnpm
-[discord]: https://discord.gg/ZmtBAhwWhy
-[i18next]: https://github.com/i18next/react-i18next
-[Weblate]: https://hosted.weblate.org/engage/invokeai/
-[openapi-typescript]: https://github.com/drwpow/openapi-typescript
-[Type generation]: #type-generation
-[schema.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/services/api/schema.ts
-[conventional commits]: https://www.conventionalcommits.org/en/v1.0.0/
-[Workflows - Design and Implementation]: ./WORKFLOWS.md
-[State Management]: ./STATE_MGMT.md
diff --git a/docs/contributing/frontend/STATE_MGMT.md b/docs/contributing/frontend/STATE_MGMT.md
deleted file mode 100644
index 443446c01c6..00000000000
--- a/docs/contributing/frontend/STATE_MGMT.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# State Management
-
-The app makes heavy use of Redux Toolkit, its Query library, and `nanostores`.
-
-## Redux
-
-TODO
-
-## `nanostores`
-
-[nanostores] is a tiny state management library. It provides both imperative and declarative APIs.
-
-### Example
-
-```ts
-export const $myStringOption = atom(null);
-
-// Outside a component, or within a callback for performance-critical logic
-$myStringOption.get();
-$myStringOption.set('new value');
-
-// Inside a component
-const myStringOption = useStore($myStringOption);
-```
-
-### Where to put nanostores
-
-- For global application state, export your stores from `invokeai/frontend/web/src/app/store/nanostores/`.
-- For feature state, create a file for the stores next to the redux slice definition (e.g. `invokeai/frontend/web/src/features/myFeature/myFeatureNanostores.ts`).
-- For hooks with global state, export the store from the same file the hook is in, or put it next to the hook.
-
-### When to use nanostores
-
-- For non-serializable data that needs to be available throughout the app, use `nanostores` instead of a global.
-- For ephemeral global state (i.e. state that does not need to be persisted), use `nanostores` instead of redux.
-- For performance-critical code and in callbacks, redux selectors can be problematic due to the declarative reactivity system. Consider refactoring to use `nanostores` if there's a **measurable** performance issue.
-
-[nanostores]: https://github.com/nanostores/nanostores/
diff --git a/docs/contributing/frontend/WORKFLOWS.md b/docs/contributing/frontend/WORKFLOWS.md
deleted file mode 100644
index 533419e0702..00000000000
--- a/docs/contributing/frontend/WORKFLOWS.md
+++ /dev/null
@@ -1,314 +0,0 @@
-# Workflows - Design and Implementation
-
-> This document describes, at a high level, the design and implementation of workflows in the InvokeAI frontend. There are a substantial number of implementation details not included, but which are hopefully clear from the code.
-
-InvokeAI's backend uses graphs, composed of **nodes** and **edges**, to process data and generate images.
-
-Nodes have any number of **input fields** and **output fields**. Edges connect nodes together via their inputs and outputs. Fields have data types which dictate how they may be connected.
-
-During execution, a nodes' outputs may be passed along to any number of other nodes' inputs.
-
-Workflows are an enriched abstraction over a graph.
-
-## Design
-
-InvokeAI provide two ways to build graphs in the frontend: the [Linear UI](#linear-ui) and [Workflow Editor](#workflow-editor).
-
-To better understand the use case and challenges related to workflows, we will review both of these modes.
-
-### Linear UI
-
-This includes the **Text to Image**, **Image to Image** and **Unified Canvas** tabs.
-
-The user-managed parameters on these tabs are stored as simple objects in the application state. When the user invokes, adding a generation to the queue, we internally build a graph from these parameters.
-
-This logic can be fairly complex due to the range of features available and their interactions. Depending on the parameters selected, the graph may be very different. Building graphs in code can be challenging - you are trying to construct a non-linear structure in a linear context.
-
-The simplest graph building logic is for **Text to Image** with a SD1.5 model: [buildLinearTextToImageGraph.ts]
-
-There are many other graph builders in the same directory for different tabs or base models (e.g. SDXL). Some are pretty hairy.
-
-In the Linear UI, we go straight from **simple application state** to **graph** via these builders.
-
-### Workflow Editor
-
-The Workflow Editor is a visual graph editor, allowing users to draw edges from node to node to construct a graph. This _far_ more approachable way to create complex graphs.
-
-InvokeAI uses the [reactflow] library to power the Workflow Editor. It provides both a graph editor UI and manages its own internal graph state.
-
-#### Workflows
-
-A workflow is a representation of a graph plus additional metadata:
-
-- Name
-- Description
-- Version
-- Notes
-- [Exposed fields](#workflow-linear-view)
-- Author, tags, category, etc.
-
-Workflows should have other qualities:
-
-- Portable: you should be able to load a workflow created by another person.
-- Resilient: you should be able to "upgrade" a workflow as the application changes.
-- Abstract: as much as is possible, workflows should not be married to the specific implementation details of the application.
-
-To support these qualities, workflows are serializable, have a versioned schemas, and represent graphs as minimally as possible. Fortunately, the reactflow state for nodes and edges works perfectly for this.
-
-##### Workflow -> reactflow state -> InvokeAI graph
-
-Given a workflow, we need to be able to derive reactflow state and/or an InvokeAI graph from it.
-
-The first step - workflow to reactflow state - is very simple. The logic is in [nodesSlice.ts], in the `workflowLoaded` reducer.
-
-The reactflow state is, however, structurally incompatible with our backend's graph structure. When a user invokes on a Workflow, we need to convert the reactflow state into an InvokeAI graph. This is far simpler than the graph building logic from the Linear UI:
-[buildNodesGraph.ts]
-
-##### Nodes vs Invocations
-
-We often use the terms "node" and "invocation" interchangeably, but they may refer to different things in the frontend.
-
-reactflow [has its own definitions][reactflow-concepts] of "node", "edge" and "handle" which are closely related to InvokeAI graph concepts.
-
-- A reactflow node is related to an InvokeAI invocation. It has a "data" property, which holds the InvokeAI-specific invocation data.
-- A reactflow edge is roughly equivalent to an InvokeAI edge.
-- A reactflow handle is roughly equivalent to an InvokeAI input or output field.
-
-##### Workflow Linear View
-
-Graphs are very capable data structures, but not everyone wants to work with them all the time.
-
-To allow less technical users - or anyone who wants a less visually noisy workspace - to benefit from the power of nodes, InvokeAI has a workflow feature called the Linear View.
-
-A workflow input field can be added to this Linear View, and its input component can be presented similarly to the Linear UI tabs. Internally, we add the field to the workflow's list of exposed fields.
-
-#### OpenAPI Schema
-
-OpenAPI is a schema specification that can represent complex data structures and relationships. The backend is capable of generating an OpenAPI schema for all invocations.
-
-When the UI connects, it requests this schema and parses each invocation into an **invocation template**. Invocation templates have a number of properties, like title, description and type, but the most important ones are their input and output **field templates**.
-
-Invocation and field templates are the "source of truth" for graphs, because they indicate what the backend is able to process.
-
-When a user adds a new node to their workflow, these templates are used to instantiate a node with fields instantiated from the input and output field templates.
-
-##### Field Instances and Templates
-
-Field templates consist of:
-
-- Name: the identifier of the field, its variable name in python
-- Type: derived from the field's type annotation in python (e.g. IntegerField, ImageField, MainModelField)
-- Constraints: derived from the field's creation args in python (e.g. minimum value for an integer)
-- Default value: optionally provided in the field's creation args (e.g. 42 for an integer)
-
-Field instances are created from the templates and have name, type and optionally a value.
-
-The type of the field determines the UI components that are rendered for it.
-
-A field instance's name associates it with its template.
-
-##### Stateful vs Stateless Fields
-
-**Stateful** fields store their value in the frontend graph. Think primitives, model identifiers, images, etc. Fields are only stateful if the frontend allows the user to directly input a value for them.
-
-Many field types, however, are **stateless**. An example is a `UNetField`, which contains some data describing a UNet. Users cannot directly provide this data - it is created and consumed in the backend.
-
-Stateless fields do not store their value in the node, so their field instances do not have values.
-
-"Custom" fields will always be treated as stateless fields.
-
-##### Single and Collection Fields
-
-Field types have a name and cardinality property which may identify it as a **SINGLE**, **COLLECTION** or **SINGLE_OR_COLLECTION** field.
-
-- If a field is annotated in python as a singular value or class, its field type is parsed as a **SINGLE** type (e.g. `int`, `ImageField`, `str`).
-- If a field is annotated in python as a list, its field type is parsed as a **COLLECTION** type (e.g. `list[int]`).
-- If it is annotated as a union of a type and list, the type will be parsed as a **SINGLE_OR_COLLECTION** type (e.g. `Union[int, list[int]]`). Fields may not be unions of different types (e.g. `Union[int, list[str]]` and `Union[int, str]` are not allowed).
-
-## Implementation
-
-The majority of data structures in the backend are [pydantic] models. Pydantic provides OpenAPI schemas for all models and we then generate TypeScript types from those.
-
-The OpenAPI schema is parsed at runtime into our invocation templates.
-
-Workflows and all related data are modeled in the frontend using [zod]. Related types are inferred from the zod schemas.
-
-> In python, invocations are pydantic models with fields. These fields become node inputs. The invocation's `invoke()` function returns a pydantic model - its output. Like the invocation itself, the output model has any number of fields, which become node outputs.
-
-### zod Schemas and Types
-
-The zod schemas, inferred types, and type guards are in [types/].
-
-Roughly order from lowest-level to highest:
-
-- `common.ts`: stateful field data, and couple other misc types
-- `field.ts`: fields - types, values, instances, templates
-- `invocation.ts`: invocations and other node types
-- `workflow.ts`: workflows and constituents
-
-We customize the OpenAPI schema to include additional properties on invocation and field schemas. To facilitate parsing this schema into templates, we modify/wrap the types from [openapi-types] in `openapi.ts`.
-
-### OpenAPI Schema Parsing
-
-The entrypoint for OpenAPI schema parsing is [parseSchema.ts].
-
-General logic flow:
-
-- Iterate over all invocation schema objects
- - Extract relevant invocation-level attributes (e.g. title, type, version, etc)
- - Iterate over the invocation's input fields
- - [Parse each field's type](#parsing-field-types)
- - [Build a field input template](#building-field-input-templates) from the type - either a stateful template or "generic" stateless template
- - Iterate over the invocation's output fields
- - Parse the field's type (same as inputs)
- - [Build a field output template](#building-field-output-templates)
- - Assemble the attributes and fields into an invocation template
-
-Most of these involve very straightforward `reduce`s, but the less intuitive steps are detailed below.
-
-#### Parsing Field Types
-
-Field types are represented as structured objects:
-
-```ts
-type FieldType = {
- name: string;
- cardinality: 'SINGLE' | 'COLLECTION' | 'SINGLE_OR_COLLECTION';
-};
-```
-
-The parsing logic is in `parseFieldType.ts`.
-
-There are 4 general cases for field type parsing.
-
-##### Primitive Types
-
-When a field is annotated as a primitive values (e.g. `int`, `str`, `float`), the field type parsing is fairly straightforward. The field is represented by a simple OpenAPI **schema object**, which has a `type` property.
-
-We create a field type name from this `type` string (e.g. `string` -> `StringField`). The cardinality is `"SINGLE"`.
-
-##### Complex Types
-
-When a field is annotated as a pydantic model (e.g. `ImageField`, `MainModelField`, `ControlField`), it is represented as a **reference object**. Reference objects are pointers to another schema or reference object within the schema.
-
-We need to **dereference** the schema to pull these out. Dereferencing may require recursion. We use the reference object's name directly for the field type name.
-
-> Unfortunately, at this time, we've had limited success using external libraries to deference at runtime, so we do this ourselves.
-
-##### Collection Types
-
-When a field is annotated as a list of a single type, the schema object has an `items` property. They may be a schema object or reference object and must be parsed to determine the item type.
-
-We use the item type for field type name. The cardinality is `"COLLECTION"`.
-
-##### Single or Collection Types
-
-When a field is annotated as a union of a type and list of that type, the schema object has an `anyOf` property, which holds a list of valid types for the union.
-
-After verifying that the union has two members (a type and list of the same type), we use the type for field type name, with cardinality `"SINGLE_OR_COLLECTION"`.
-
-##### Optional Fields
-
-In OpenAPI v3.1, when an object is optional, it is put into an `anyOf` along with a primitive schema object with `type: 'null'`.
-
-Handling this adds a fair bit of complexity, as we now must filter out the `'null'` types and work with the remaining types as described above.
-
-If there is a single remaining schema object, we must recursively call to `parseFieldType()` to get parse it.
-
-#### Building Field Input Templates
-
-Now that we have a field type, we can build an input template for the field.
-
-Stateful fields all get a function to build their template, while stateless fields are constructed directly. This is possible because stateless fields have no default value or constraints.
-
-See [buildFieldInputTemplate.ts].
-
-#### Building Field Output Templates
-
-Field outputs are similar to stateless fields - they do not have any value in the frontend. When building their templates, we don't need a special function for each field type.
-
-See [buildFieldOutputTemplate.ts].
-
-### Managing reactflow State
-
-As described above, the workflow editor state is the essentially the reactflow state, plus some extra metadata.
-
-We provide reactflow with an array of nodes and edges via redux, and a number of [event handlers][reactflow-events]. These handlers dispatch redux actions, managing nodes and edges.
-
-The pieces of redux state relevant to workflows are:
-
-- `state.nodes.nodes`: the reactflow nodes state
-- `state.nodes.edges`: the reactflow edges state
-- `state.nodes.workflow`: the workflow metadata
-
-#### Building Nodes and Edges
-
-A reactflow node has a few important top-level properties:
-
-- `id`: unique identifier
-- `type`: a string that maps to a react component to render the node
-- `position`: XY coordinates
-- `data`: arbitrary data
-
-When the user adds a node, we build **invocation node data**, storing it in `data`. Invocation properties (e.g. type, version, label, etc.) are copied from the invocation template. Inputs and outputs are built from the invocation template's field templates.
-
-See [buildInvocationNode.ts].
-
-Edges are managed by reactflow, but briefly, they consist of:
-
-- `source`: id of the source node
-- `sourceHandle`: id of the source node handle (output field)
-- `target`: id of the target node
-- `targetHandle`: id of the target node handle (input field)
-
-> Edge creation is gated behind validation logic. This validation compares the input and output field types and overall graph state.
-
-#### Building a Workflow
-
-Building a workflow entity is as simple as dropping the nodes, edges and metadata into an object.
-
-Each node and edge is parsed with a zod schema, which serves to strip out any unneeded data.
-
-See [buildWorkflow.ts].
-
-#### Loading a Workflow
-
-Workflows may be loaded from external sources or the user's local instance. In all cases, the workflow needs to be handled with care, as an untrusted object.
-
-Loading has a few stages which may throw or warn if there are problems:
-
-- Parsing the workflow data structure itself, [migrating](#workflow-migrations) it if necessary (throws)
-- Check for a template for each node (warns)
-- Check each node's version against its template (warns)
-- Validate the source and target of each edge (warns)
-
-This validation occurs in [validateWorkflow.ts].
-
-If there are no fatal errors, the workflow is then stored in redux state.
-
-### Workflow Migrations
-
-When the workflow schema changes, we may need to perform some data migrations. This occurs as workflows are loaded. zod schemas for each workflow schema version is retained to facilitate migrations.
-
-Previous schemas are in folders in `invokeai/frontend/web/src/features/nodes/types/`, eg `v1/`.
-
-Migration logic is in [migrations.ts].
-
-
-
-[pydantic]: https://github.com/pydantic/pydantic 'pydantic'
-[zod]: https://github.com/colinhacks/zod 'zod'
-[openapi-types]: https://github.com/kogosoftwarellc/open-api/tree/main/packages/openapi-types 'openapi-types'
-[reactflow]: https://github.com/xyflow/xyflow 'reactflow'
-[reactflow-concepts]: https://reactflow.dev/learn/concepts/terms-and-definitions
-[reactflow-events]: https://reactflow.dev/api-reference/react-flow#event-handlers
-[buildWorkflow.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts
-[nodesSlice.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
-[buildLinearTextToImageGraph.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
-[buildNodesGraph.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
-[buildInvocationNode.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts
-[validateWorkflow.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
-[migrations.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts
-[parseSchema.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts
-[buildFieldInputTemplate.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
-[buildFieldOutputTemplate.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts
diff --git a/docs/features/CONFIGURATION.md b/docs/features/CONFIGURATION.md
deleted file mode 100644
index d6bfe44901c..00000000000
--- a/docs/features/CONFIGURATION.md
+++ /dev/null
@@ -1,184 +0,0 @@
----
-title: Configuration
----
-
-# :material-tune-variant: InvokeAI Configuration
-
-## Intro
-
-Runtime settings, including the location of files and
-directories, memory usage, and performance, are managed via the
-`invokeai.yaml` config file or environment variables. A subset
-of settings may be set via commandline arguments.
-
-Settings sources are used in this order:
-
-- CLI args
-- Environment variables
-- `invokeai.yaml` settings
-- Fallback: defaults
-
-### InvokeAI Root Directory
-
-On startup, InvokeAI searches for its "root" directory. This is the directory
-that contains models, images, the database, and so on. It also contains
-a configuration file called `invokeai.yaml`.
-
-InvokeAI searches for the root directory in this order:
-
-1. The `--root ` CLI arg.
-2. The environment variable INVOKEAI_ROOT.
-3. The directory containing the currently active virtual environment.
-4. Fallback: a directory in the current user's home directory named `invokeai`.
-
-### InvokeAI Configuration File
-
-Inside the root directory, we read settings from the `invokeai.yaml` file.
-
-It has two sections - one for internal use and one for user settings:
-
-```yaml
-# Internal metadata - do not edit:
-schema_version: 4
-
-# Put user settings here - see https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/:
-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
-```
-
-The settings in this file will override the defaults. You only need
-to change this file if the default for a particular setting doesn't
-work for you.
-
-You'll find an example file next to `invokeai.yaml` that shows the default values.
-
-Some settings, like [Model Marketplace API Keys], require the YAML
-to be formatted correctly. Here is a [basic guide to YAML files].
-
-#### Custom Config File Location
-
-You can use any config file with the `--config` CLI arg. Pass in the path to the `invokeai.yaml` file you want to use.
-
-Note that environment variables will trump any settings in the config file.
-
-### Environment Variables
-
-All settings may be set via environment variables by prefixing `INVOKEAI_`
-to the variable name. For example, `INVOKEAI_HOST` would set the `host`
-setting.
-
-For non-primitive values, pass a JSON-encoded string:
-
-```sh
-export INVOKEAI_REMOTE_API_TOKENS='[{"url_regex":"modelmarketplace", "token": "12345"}]'
-```
-
-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
-
-### All Settings
-
-Following the table are additional explanations for certain settings.
-
-
-::: invokeai.app.services.config.config_default.InvokeAIAppConfig
- options:
- heading_level: 4
- members: false
- show_docstring_description: false
- group_by_category: true
- show_category_heading: false
-
-
-#### 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.
-
-#### 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:
-
-```
- 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.
-
-```
- 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.
-
-[basic guide to yaml files]: https://circleci.com/blog/what-is-yaml-a-beginner-s-guide/
-[Model Marketplace API Keys]: #model-marketplace-api-keys
diff --git a/docs/features/CONTROLNET.md b/docs/features/CONTROLNET.md
deleted file mode 100644
index 718b12b0f8b..00000000000
--- a/docs/features/CONTROLNET.md
+++ /dev/null
@@ -1,181 +0,0 @@
----
-title: Control Adapters
----
-
-# :material-loupe: Control Adapters
-
-## ControlNet
-
-ControlNet is a powerful set of features developed by the open-source
-community (notably, Stanford researcher
-[**@ilyasviel**](https://github.com/lllyasviel)) that allows you to
-apply a secondary neural network model to your image generation
-process in Invoke.
-
-With ControlNet, you can get more control over the output of your
-image generation, providing you with a way to direct the network
-towards generating images that better fit your desired style or
-outcome.
-
-ControlNet works by analyzing an input image, pre-processing that
-image to identify relevant information that can be interpreted by each
-specific ControlNet model, and then inserting that control information
-into the generation process. This can be used to adjust the style,
-composition, or other aspects of the image to better achieve a
-specific result.
-
-#### Installation
-
-InvokeAI provides access to a series of ControlNet models that provide
-different effects or styles in your generated images.
-
-To install ControlNet Models:
-
-1. The easiest way to install them is
-to use the InvokeAI model installer application. Use the
-`invoke.sh`/`invoke.bat` launcher to select item [4] and then navigate
-to the CONTROLNETS section. Select the models you wish to install and
-press "APPLY CHANGES". You may also enter additional HuggingFace
-repo_ids in the "Additional models" textbox.
-2. Using the "Add Model" function of the model manager, enter the HuggingFace Repo ID of the ControlNet. The ID is in the format "author/repoName"
-
-
-_Be aware that some ControlNet models require additional code
-functionality in order to work properly, so just installing a
-third-party ControlNet model may not have the desired effect._ Please
-read and follow the documentation for installing a third party model
-not currently included among InvokeAI's default list.
-
-Currently InvokeAI **only** supports 🤗 Diffusers-format ControlNet models. These are
-folders that contain the files `config.json` and/or
-`diffusion_pytorch_model.safetensors` and
-`diffusion_pytorch_model.fp16.safetensors`. The name of the folder is
-the name of the model.
-
-🤗 Diffusers-format ControlNet models are available at HuggingFace
-(http://huggingface.co) and accessed via their repo IDs (identifiers
-in the format "author/modelname").
-
-#### ControlNet Models
-The models currently supported include:
-
-**Canny**:
-
-When the Canny model is used in ControlNet, Invoke will attempt to generate images that match the edges detected.
-
-Canny edge detection works by detecting the edges in an image by looking for abrupt changes in intensity. It is known for its ability to detect edges accurately while reducing noise and false edges, and the preprocessor can identify more information by decreasing the thresholds.
-
-**M-LSD**:
-
-M-LSD is another edge detection algorithm used in ControlNet. It stands for Multi-Scale Line Segment Detector.
-
-It detects straight line segments in an image by analyzing the local structure of the image at multiple scales. It can be useful for architectural imagery, or anything where straight-line structural information is needed for the resulting output.
-
-**Lineart**:
-
-The Lineart model in ControlNet generates line drawings from an input image. The resulting pre-processed image is a simplified version of the original, with only the outlines of objects visible.The Lineart model in ControlNet is known for its ability to accurately capture the contours of the objects in an input sketch.
-
-**Lineart Anime**:
-
-A variant of the Lineart model that generates line drawings with a distinct style inspired by anime and manga art styles.
-
-**Depth**:
-A model that generates depth maps of images, allowing you to create more realistic 3D models or to simulate depth effects in post-processing.
-
-**Normal Map (BAE):**
-A model that generates normal maps from input images, allowing for more realistic lighting effects in 3D rendering.
-
-**Image Segmentation**:
-A model that divides input images into segments or regions, each of which corresponds to a different object or part of the image. (More details coming soon)
-
-**QR Code Monster**:
-A model that helps generate creative QR codes that still scan. Can also be used to create images with text, logos or shapes within them.
-
-**Openpose**:
-The OpenPose control model allows for the identification of the general pose of a character by pre-processing an existing image with a clear human structure. With advanced options, Openpose can also detect the face or hands in the image.
-
-*Note:* The DWPose Processor has replaced the OpenPose processor in Invoke. Workflows and generations that relied on the OpenPose Processor will need to be updated to use the DWPose Processor instead.
-
-**Mediapipe Face**:
-
-The MediaPipe Face identification processor is able to clearly identify facial features in order to capture vivid expressions of human faces.
-
-**Tile**:
-
-The Tile model fills out details in the image to match the image, rather than the prompt. The Tile Model is a versatile tool that offers a range of functionalities. Its primary capabilities can be boiled down to two main behaviors:
-
-- It can reinterpret specific details within an image and create fresh, new elements.
-- It has the ability to disregard global instructions if there's a discrepancy between them and the local context or specific parts of the image. In such cases, it uses the local context to guide the process.
-
-The Tile Model can be a powerful tool in your arsenal for enhancing image quality and details. If there are undesirable elements in your images, such as blurriness caused by resizing, this model can effectively eliminate these issues, resulting in cleaner, crisper images. Moreover, it can generate and add refined details to your images, improving their overall quality and appeal.
-
-**Pix2Pix (experimental)**
-
-With Pix2Pix, you can input an image into the controlnet, and then "instruct" the model to change it using your prompt. For example, you can say "Make it winter" to add more wintry elements to a scene.
-
-Each of these models can be adjusted and combined with other ControlNet models to achieve different results, giving you even more control over your image generation process.
-
-
-### Using ControlNet
-
-To use ControlNet, you can simply select the desired model and adjust both the ControlNet and Pre-processor settings to achieve the desired result. You can also use multiple ControlNet models at the same time, allowing you to achieve even more complex effects or styles in your generated images.
-
-
-Each ControlNet has two settings that are applied to the ControlNet.
-
-Weight - Strength of the Controlnet model applied to the generation for the section, defined by start/end.
-
-Start/End - 0 represents the start of the generation, 1 represents the end. The Start/end setting controls what steps during the generation process have the ControlNet applied.
-
-Additionally, each ControlNet section can be expanded in order to manipulate settings for the image pre-processor that adjusts your uploaded image before using it in when you Invoke.
-
-## T2I-Adapter
-[T2I-Adapter](https://github.com/TencentARC/T2I-Adapter) is a tool similar to ControlNet that allows for control over the generation process by providing control information during the generation process. T2I-Adapter models tend to be smaller and more efficient than ControlNets.
-
-##### Installation
-To install T2I-Adapter Models:
-
-1. The easiest way to install models is
-to use the InvokeAI model installer application. Use the
-`invoke.sh`/`invoke.bat` launcher to select item [5] and then navigate
-to the T2I-Adapters section. Select the models you wish to install and
-press "APPLY CHANGES". You may also enter additional HuggingFace
-repo_ids in the "Additional models" textbox.
-2. Using the "Add Model" function of the model manager, enter the HuggingFace Repo ID of the T2I-Adapter. The ID is in the format "author/repoName"
-
-#### Usage
-Each T2I Adapter has two settings that are applied.
-
-Weight - Strength of the model applied to the generation for the section, defined by start/end.
-
-Start/End - 0 represents the start of the generation, 1 represents the end. The Start/end setting controls what steps during the generation process have the ControlNet applied.
-
-Additionally, each section can be expanded with the "Show Advanced" button in order to manipulate settings for the image pre-processor that adjusts your uploaded image before using it in during the generation process.
-
-
-## IP-Adapter
-
-[IP-Adapter](https://ip-adapter.github.io) is a tooling that allows for image prompt capabilities with text-to-image diffusion models. IP-Adapter works by analyzing the given image prompt to extract features, then passing those features to the UNet along with any other conditioning provided.
-
-
-
-
-
-#### Installation
-There are several ways to install IP-Adapter models with an existing InvokeAI installation:
-
-1. Through the command line interface launched from the invoke.sh / invoke.bat scripts, option [4] to download models.
-2. Through the Model Manager UI with models from the *Tools* section of [models.invoke.ai](https://models.invoke.ai). To do this, copy the repo ID from the desired model page, and paste it in the Add Model field of the model manager. **Note** Both the IP-Adapter and the Image Encoder must be installed for IP-Adapter to work. For example, the [SD 1.5 IP-Adapter](https://models.invoke.ai/InvokeAI/ip_adapter_plus_sd15) and [SD1.5 Image Encoder](https://models.invoke.ai/InvokeAI/ip_adapter_sd_image_encoder) must be installed to use IP-Adapter with SD1.5 based models.
-3. **Advanced -- Not recommended ** Manually downloading the IP-Adapter and Image Encoder files - Image Encoder folders shouid be placed in the `models\any\clip_vision` folders. IP Adapter Model folders should be placed in the relevant `ip-adapter` folder of relevant base model folder of Invoke root directory. For example, for the SDXL IP-Adapter, files should be added to the `model/sdxl/ip_adapter/` folder.
-
-#### Using IP-Adapter
-
-IP-Adapter can be used by navigating to the *Control Adapters* options and enabling IP-Adapter.
-
-IP-Adapter requires an image to be used as the Image Prompt. It can also be used in conjunction with text prompts, Image-to-Image, Inpainting, Outpainting, ControlNets and LoRAs.
-
-
-Each IP-Adapter has two settings that are applied to the IP-Adapter:
-
-* Weight - Strength of the IP-Adapter model applied to the generation for the section, defined by start/end
-* Start/End - 0 represents the start of the generation, 1 represents the end. The Start/end setting controls what steps during the generation process have the IP-Adapter applied.
diff --git a/docs/features/DATABASE.md b/docs/features/DATABASE.md
deleted file mode 100644
index 85829bef868..00000000000
--- a/docs/features/DATABASE.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-title: Database
----
-
-# Invoke's SQLite Database
-
-Invoke uses a SQLite database to store image, workflow, model, and execution data.
-
-We take great care to ensure your data is safe, by utilizing transactions and a database migration system.
-
-Even so, when testing an prerelease version of the app, we strongly suggest either backing up your database or using an in-memory database. This ensures any prelease hiccups or databases schema changes will not cause problems for your data.
-
-## Database Backup
-
-Backing up your database is very simple. Invoke's data is stored in an `$INVOKEAI_ROOT` directory - where your `invoke.sh`/`invoke.bat` and `invokeai.yaml` files live.
-
-To back up your database, copy the `invokeai.db` file from `$INVOKEAI_ROOT/databases/invokeai.db` to somewhere safe.
-
-If anything comes up during prelease testing, you can simply copy your backup back into `$INVOKEAI_ROOT/databases/`.
-
-## In-Memory Database
-
-SQLite can run on an in-memory database. Your existing database is untouched when this mode is enabled, but your existing data won't be accessible.
-
-This is very useful for testing, as there is no chance of a database change modifying your "physical" database.
-
-To run Invoke with a memory database, edit your `invokeai.yaml` file, and add `use_memory_db: true` to the `Paths:` stanza:
-
-```yaml
-InvokeAI:
- Development:
- use_memory_db: true
-```
-
-Delete this line (or set it to `false`) to use your main database.
diff --git a/docs/features/GALLERY.md b/docs/features/GALLERY.md
deleted file mode 100644
index cc84dbf704e..00000000000
--- a/docs/features/GALLERY.md
+++ /dev/null
@@ -1,92 +0,0 @@
----
-title: InvokeAI Gallery Panel
----
-
-# :material-web: InvokeAI Gallery Panel
-
-## Quick guided walkthrough of the Gallery Panel's features
-
-The Gallery Panel is a fast way to review, find, and make use of images you've
-generated and loaded. The Gallery is divided into Boards. The Uncategorized board is always
-present but you can create your own for better organization.
-
-
-
-### Board Display and Settings
-
-At the very top of the Gallery Panel are the boards disclosure and settings buttons.
-
-
-
-The disclosure button shows the name of the currently selected board and allows you to show and hide the board thumbnails (shown in the image below).
-
-
-
-The settings button opens a list of options.
-
-
-
-- ***Image Size*** this slider lets you control the size of the image previews (images of three different sizes).
-- ***Auto-Switch to New Images*** if you turn this on, whenever a new image is generated, it will automatically be loaded into the current image panel on the Text to Image tab and into the result panel on the [Image to Image](IMG2IMG.md) tab. This will happen invisibly if you are on any other tab when the image is generated.
-- ***Auto-Assign Board on Click*** whenever an image is generated or saved, it always gets put in a board. The board it gets put into is marked with AUTO (image of board marked). Turning on Auto-Assign Board on Click will make whichever board you last selected be the destination when you click Invoke. That means you can click Invoke, select a different board, and then click Invoke again and the two images will be put in two different boards. (bold)It's the board selected when Invoke is clicked that's used, not the board that's selected when the image is finished generating.(bold) Turning this off, enables the Auto-Add Board drop down which lets you set one specific board to always put generated images into. This also enables and disables the Auto-add to this Board menu item described below.
-- ***Always Show Image Size Badge*** this toggles whether to show image sizes for each image preview (show two images, one with sizes shown, one without)
-
-Below these two buttons, you'll see the Search Boards text entry area. You use this to search for specific boards by the name of the board.
-Next to it is the Add Board (+) button which lets you add new boards. Boards can be renamed by clicking on the name of the board under its thumbnail and typing in the new name.
-
-### Board Thumbnail Menu
-
-Each board has a context menu (ctrl+click / right-click).
-
-
-
-- ***Auto-add to this Board*** if you've disabled Auto-Assign Board on Click in the board settings, you can use this option to set this board to be where new images are put.
-- ***Download Board*** this will add all the images in the board into a zip file and provide a link to it in a notification (image of notification)
-- ***Delete Board*** this will delete the board
-> [!CAUTION]
-> This will delete all the images in the board and the board itself.
-
-### Board Contents
-
-Every board is organized by two tabs, Images and Assets.
-
-
-
-Images are the Invoke-generated images that are placed into the board. Assets are images that you upload into Invoke to be used as an [Image Prompt](https://support.invoke.ai/support/solutions/articles/151000159340-using-the-image-prompt-adapter-ip-adapter-) or in the [Image to Image](IMG2IMG.md) tab.
-
-### Image Thumbnail Menu
-
-Every image generated by Invoke has its generation information stored as text inside the image file itself. This can be read directly by selecting the image and clicking on the Info button  in any of the image result panels.
-
-Each image also has a context menu (ctrl+click / right-click).
-
-
-
- The options are (items marked with an * will not work with images that lack generation information):
-- ***Open in New Tab*** this will open the image alone in a new browser tab, separate from the Invoke interface.
-- ***Download Image*** this will trigger your browser to download the image.
-- ***Load Workflow **** this will load any workflow settings into the Workflow tab and automatically open it.
-- ***Remix Image **** this will load all of the image's generation information, (bold)excluding its Seed, into the left hand control panel
-- ***Use Prompt **** this will load only the image's text prompts into the left-hand control panel
-- ***Use Seed **** this will load only the image's Seed into the left-hand control panel
-- ***Use All **** this will load all of the image's generation information into the left-hand control panel
-- ***Send to Image to Image*** this will put the image into the left-hand panel in the Image to Image tab ana automatically open it
-- ***Send to Unified Canvas*** This will (bold)replace whatever is already present(bold) in the Unified Canvas tab with the image and automatically open the tab
-- ***Change Board*** this will oipen a small window that will let you move the image to a different board. This is the same as dragging the image to that board's thumbnail.
-- ***Star Image*** this will add the image to the board's list of starred images that are always kept at the top of the gallery. This is the same as clicking on the star on the top right-hand side of the image that appears when you hover over the image with the mouse
-- ***Delete Image*** this will delete the image from the board
-> [!CAUTION]
-> This will delete the image entirely from Invoke.
-
-## Summary
-
-This walkthrough only covers the Gallery interface and Boards. Actually generating images is handled by [Prompts](PROMPTS.md), the [Image to Image](IMG2IMG.md) tab, and the [Unified Canvas](UNIFIED_CANVAS.md).
-
-## Acknowledgements
-
-A huge shout-out to the core team working to make the Web GUI a reality,
-including [psychedelicious](https://github.com/psychedelicious),
-[Kyle0654](https://github.com/Kyle0654) and
-[blessedcoolant](https://github.com/blessedcoolant).
-[hipsterusername](https://github.com/hipsterusername) was the team's unofficial
-cheerleader and added tooltips/docs.
diff --git a/docs/features/IMG2IMG.md b/docs/features/IMG2IMG.md
deleted file mode 100644
index 046a25fdca0..00000000000
--- a/docs/features/IMG2IMG.md
+++ /dev/null
@@ -1,151 +0,0 @@
----
-title: Image-to-Image
----
-
-# :material-image-multiple: Image-to-Image
-
-InvokeAI provides an "img2img" feature that lets you seed your
-creations with an initial drawing or photo. This is a really cool
-feature that tells stable diffusion to build the prompt on top of the
-image you provide, preserving the original's basic shape and layout.
-
-For a walkthrough of using Image-to-Image in the Web UI, see [InvokeAI
-Web Server](./WEB.md#image-to-image).
-
-The main difference between `img2img` and `prompt2img` is the starting point.
-While `prompt2img` always starts with pure gaussian noise and progressively
-refines it over the requested number of steps, `img2img` skips some of these
-earlier steps (how many it skips is indirectly controlled by the `--strength`
-parameter), and uses instead your initial image mixed with gaussian noise as the
-starting image.
-
-**Let's start** by thinking about vanilla `prompt2img`, just generating an image
-from a prompt. If the step count is 10, then the "latent space" (Stable
-Diffusion's internal representation of the image) for the prompt "fire" with
-seed `1592514025` develops something like this:
-
-!!! example ""
-
-
- { width=720 }
-
-
-Put simply: starting from a frame of fuzz/static, SD finds details in each frame
-that it thinks look like "fire" and brings them a little bit more into focus,
-gradually scrubbing out the fuzz until a clear image remains.
-
-**When you use `img2img`** some of the earlier steps are cut, and instead an
-initial image of your choice is used. But because of how the maths behind Stable
-Diffusion works, this image needs to be mixed with just the right amount of
-noise (fuzz/static) for where it is being inserted. This is where the strength
-parameter comes in. Depending on the set strength, your image will be inserted
-into the sequence at the appropriate point, with just the right amount of noise.
-
-### A concrete example
-
-!!! example "I want SD to draw a fire based on this hand-drawn image"
-
- { align=left }
-
- Let's only do 10 steps, to make it easier to see what's happening. If strength
- is `0.7`, this is what the internal steps the algorithm has to take will look
- like:
-
-
- 
-
-
- With strength `0.4`, the steps look more like this:
-
-
- 
-
-
-Notice how much more fuzzy the starting image is for strength `0.7` compared to
-`0.4`, and notice also how much longer the sequence is with `0.7`:
-
-| | strength = 0.7 | strength = 0.4 |
-| --------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |
-| initial image that SD sees |  |  |
-| steps argument to `invoke>` | `-S10` | `-S10` |
-| steps actually taken | `7` | `4` |
-| latent space at each step |  |  |
-| output |  |  |
-
-Both of the outputs look kind of like what I was thinking of. With the strength
-higher, my input becomes more vague, _and_ Stable Diffusion has more steps to
-refine its output. But it's not really making what I want, which is a picture of
-cheery open fire. With the strength lower, my input is more clear, _but_ Stable
-Diffusion has less chance to refine itself, so the result ends up inheriting all
-the problems of my bad drawing.
-
-If you want to try this out yourself, all of these are using a seed of
-`1592514025` with a width/height of `384`, step count `10`, the
-`k_lms` sampler, and the single-word prompt `"fire"`.
-
-### Compensating for the reduced step count
-
-After putting this guide together I was curious to see how the difference would
-be if I increased the step count to compensate, so that SD could have the same
-amount of steps to develop the image regardless of the strength. So I ran the
-generation again using the same seed, but this time adapting the step count to
-give each generation 20 steps.
-
-Here's strength `0.4` (note step count `50`, which is `20 ÷ 0.4` to make sure SD
-does `20` steps from my image):
-
-
-
-
-
-and here is strength `0.7` (note step count `30`, which is roughly `20 ÷ 0.7` to
-make sure SD does `20` steps from my image):
-
-
-
-
-
-In both cases the image is nice and clean and "finished", but because at
-strength `0.7` Stable Diffusion has been give so much more freedom to improve on
-my badly-drawn flames, they've come out looking much better. You can really see
-the difference when looking at the latent steps. There's more noise on the first
-image with strength `0.7`:
-
-
-
-
-
-than there is for strength `0.4`:
-
-
-
-
-
-and that extra noise gives the algorithm more choices when it is evaluating how
-to denoise any particular pixel in the image.
-
-Unfortunately, it seems that `img2img` is very sensitive to the step count.
-Here's strength `0.7` with a step count of `29` (SD did 19 steps from my image):
-
-
-
-
-
-By comparing the latents we can sort of see that something got interpreted
-differently enough on the third or fourth step to lead to a rather different
-interpretation of the flames.
-
-
-
-
-
-
-
-
-
-This is the result of a difference in the de-noising "schedule" - basically the
-noise has to be cleaned by a certain degree each step or the model won't
-"converge" on the image properly (see
-[stable diffusion blog](https://huggingface.co/blog/stable_diffusion) for more
-about that). A different step count means a different schedule, which means
-things get interpreted slightly differently at every step.
diff --git a/docs/features/LOGGING.md b/docs/features/LOGGING.md
deleted file mode 100644
index bda968140b3..00000000000
--- a/docs/features/LOGGING.md
+++ /dev/null
@@ -1,171 +0,0 @@
----
-title: Controlling Logging
----
-
-# :material-image-off: Controlling Logging
-
-## Controlling How InvokeAI Logs Status Messages
-
-InvokeAI logs status messages using a configurable logging system. You
-can log to the terminal window, to a designated file on the local
-machine, to the syslog facility on a Linux or Mac, or to a properly
-configured web server. You can configure several logs at the same
-time, and control the level of message logged and the logging format
-(to a limited extent).
-
-Three command-line options control logging:
-
-### `--log_handlers ...`
-
-This option activates one or more log handlers. Options are "console",
-"file", "syslog" and "http". To specify more than one, separate them
-by spaces:
-
-```bash
-invokeai-web --log_handlers console syslog=/dev/log file=C:\Users\fred\invokeai.log
-```
-
-The format of these options is described below.
-
-### `--log_format {plain|color|legacy|syslog}`
-
-This controls the format of log messages written to the console. Only
-the "console" log handler is currently affected by this setting.
-
-* "plain" provides formatted messages like this:
-
-```bash
-
-[2023-05-24 23:18:2[2023-05-24 23:18:50,352]::[InvokeAI]::DEBUG --> this is a debug message
-[2023-05-24 23:18:50,352]::[InvokeAI]::INFO --> this is an informational messages
-[2023-05-24 23:18:50,352]::[InvokeAI]::WARNING --> this is a warning
-[2023-05-24 23:18:50,352]::[InvokeAI]::ERROR --> this is an error
-[2023-05-24 23:18:50,352]::[InvokeAI]::CRITICAL --> this is a critical error
-```
-
-* "color" produces similar output, but the text will be color coded to
-indicate the severity of the message.
-
-* "legacy" produces output similar to InvokeAI versions 2.3 and earlier:
-
-```bash
-### this is a critical error
-*** this is an error
-** this is a warning
->> this is an informational messages
- | this is a debug message
-```
-
-* "syslog" produces messages suitable for syslog entries:
-
-```bash
-InvokeAI [2691178] this is a critical error
-InvokeAI [2691178] this is an error
-InvokeAI [2691178] this is a warning
-InvokeAI [2691178] this is an informational messages
-InvokeAI [2691178] this is a debug message
-```
-
-(note that the date, time and hostname will be added by the syslog
-system)
-
-### `--log_level {debug|info|warning|error|critical}`
-
-Providing this command-line option will cause only messages at the
-specified level or above to be emitted.
-
-## Console logging
-
-When "console" is provided to `--log_handlers`, messages will be
-written to the command line window in which InvokeAI was launched. By
-default, the color formatter will be used unless overridden by
-`--log_format`.
-
-## File logging
-
-When "file" is provided to `--log_handlers`, entries will be written
-to the file indicated in the path argument. By default, the "plain"
-format will be used:
-
-```bash
-invokeai-web --log_handlers file=/var/log/invokeai.log
-```
-
-## Syslog logging
-
-When "syslog" is requested, entries will be sent to the syslog
-system. There are a variety of ways to control where the log message
-is sent:
-
-* Send to the local machine using the `/dev/log` socket:
-
-```
-invokeai-web --log_handlers syslog=/dev/log
-```
-
-* Send to the local machine using a UDP message:
-
-```
-invokeai-web --log_handlers syslog=localhost
-```
-
-* Send to the local machine using a UDP message on a nonstandard
- port:
-
-```
-invokeai-web --log_handlers syslog=localhost:512
-```
-
-* Send to a remote machine named "loghost" on the local LAN using
- facility LOG_USER and UDP packets:
-
-```
-invokeai-web --log_handlers syslog=loghost,facility=LOG_USER,socktype=SOCK_DGRAM
-```
-
-This can be abbreviated `syslog=loghost`, as LOG_USER and SOCK_DGRAM
-are defaults.
-
-* Send to a remote machine named "loghost" using the facility LOCAL0
- and using a TCP socket:
-
-```
-invokeai-web --log_handlers syslog=loghost,facility=LOG_LOCAL0,socktype=SOCK_STREAM
-```
-
-If no arguments are specified (just a bare "syslog"), then the logging
-system will look for a UNIX socket named `/dev/log`, and if not found
-try to send a UDP message to `localhost`. The Macintosh OS used to
-support logging to a socket named `/var/run/syslog`, but this feature
-has since been disabled.
-
-## Web logging
-
-If you have access to a web server that is configured to log messages
-when a particular URL is requested, you can log using the "http"
-method:
-
-```
-invokeai-web --log_handlers http=http://my.server/path/to/logger,method=POST
-```
-
-The optional [,method=] part can be used to specify whether the URL
-accepts GET (default) or POST messages.
-
-Currently password authentication and SSL are not supported.
-
-## Using the configuration file
-
-You can set and forget logging options by adding a "Logging" section
-to `invokeai.yaml`:
-
-```
-InvokeAI:
- [... other settings...]
- Logging:
- log_handlers:
- - console
- - syslog=/dev/log
- log_level: info
- log_format: color
-```
diff --git a/docs/features/LORAS.md b/docs/features/LORAS.md
deleted file mode 100644
index 5dcfc8bf21b..00000000000
--- a/docs/features/LORAS.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-title: LoRAs & LCM-LoRAs
----
-
-# :material-library-shelves: LoRAs & LCM-LoRAs
-
-With the advances in research, many new capabilities are available to customize the knowledge and understanding of novel concepts not originally contained in the base model.
-
-## LoRAs
-
-Low-Rank Adaptation (LoRA) files are models that customize the output of Stable Diffusion
-image generation. Larger than embeddings, but much smaller than full
-models, they augment SD with improved understanding of subjects and
-artistic styles.
-
-Unlike TI files, LoRAs do not introduce novel vocabulary into the
-model's known tokens. Instead, LoRAs augment the model's weights that
-are applied to generate imagery. LoRAs may be supplied with a
-"trigger" word that they have been explicitly trained on, or may
-simply apply their effect without being triggered.
-
-LoRAs are typically stored in .safetensors files, which are the most
-secure way to store and transmit these types of weights.
-
-To use these when generating, open the LoRA menu item in the options
-panel, select the LoRAs you want to apply and ensure that they have
-the appropriate weight recommended by the model provider. Typically,
-most LoRAs perform best at a weight of .75-1.
-
-
-## LCM-LoRAs
-Latent Consistency Models (LCMs) allowed a reduced number of steps to be used to generate images with Stable Diffusion. These are created by distilling base models, creating models that only require a small number of steps to generate images. However, LCMs require that any fine-tune of a base model be distilled to be used as an LCM.
-
-LCM-LoRAs are models that provide the benefit of LCMs but are able to be used as LoRAs and applied to any fine tune of a base model. LCM-LoRAs are created by training a small number of adapters, rather than distilling the entire fine-tuned base model. The resulting LoRA can be used the same way as a standard LoRA, but with a greatly reduced step count. This enables SDXL images to be generated up to 10x faster than without the use of LCM-LoRAs.
-
-
-**Using LCM-LoRAs**
-
-LCM-LoRAs are natively supported in InvokeAI throughout the application. To get started, install any diffusers format LCM-LoRAs using the model manager and select it in the LoRA field.
-
-There are a number parameter differences when using LCM-LoRAs and standard generation:
-
-- When using LCM-LoRAs, the LoRA strength should be lower than if using a standard LoRA, with 0.35 recommended as a starting point.
-- The LCM scheduler should be used for generation
-- CFG-Scale should be reduced to ~1
-- Steps should be reduced in the range of 4-8
-
-Standard LoRAs can also be used alongside LCM-LoRAs, but will also require a lower strength, with 0.45 being recommended as a starting point.
-
-More information can be found here: https://huggingface.co/blog/lcm_lora#fast-inference-with-sdxl-lcm-loras
diff --git a/docs/features/MODEL_MERGING.md b/docs/features/MODEL_MERGING.md
deleted file mode 100644
index e384662ef5d..00000000000
--- a/docs/features/MODEL_MERGING.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-title: Model Merging
----
-
-InvokeAI provides the ability to merge two or three diffusers-type models into a new merged model. The
-resulting model will combine characteristics of the original, and can
-be used to teach an old model new tricks.
-
-## How to Merge Models
-
-Model Merging can be be done by navigating to the Model Manager and clicking the "Merge Models" tab. From there, you can select the models and settings you want to use to merge th models.
-
-## Settings
-
-* Model Selection: there are three multiple choice fields that
- display all the diffusers-style models that InvokeAI knows about.
- If you do not see the model you are looking for, then it is probably
- a legacy checkpoint model and needs to be converted using the
- "Convert" option in the Web-based Model Manager tab.
-
- You must select at least two models to merge. The third can be left
- at "None" if you desire.
-
-* Alpha: This is the ratio to use when combining models. It ranges
- from 0 to 1. The higher the value, the more weight is given to the
- 2d and (optionally) 3d models. So if you have two models named "A"
- and "B", an alpha value of 0.25 will give you a merged model that is
- 25% A and 75% B.
-
-* Interpolation Method: This is the method used to combine
- weights. The options are "weighted_sum" (the default), "sigmoid",
- "inv_sigmoid" and "add_difference". Each produces slightly different
- results. When three models are in use, only "add_difference" is
- available.
-
-* Save Location: The location you want the merged model to be saved in. Default is in the InvokeAI root folder
-
-* Name for merged model: This is the name for the new model. Please
- use InvokeAI conventions - only alphanumeric letters and the
- characters ".+-".
-
-* Ignore Mismatches / Force: Not all models are compatible with each other. The merge
- script will check for compatibility and refuse to merge ones that
- are incompatible. Set this checkbox to try merging anyway.
-
-
-
-You may run the merge script by starting the invoke launcher
-(`invoke.sh` or `invoke.bat`) and choosing the option (4) for _merge
-models_. This will launch a text-based interactive user interface that
-prompts you to select the models to merge, how to merge them, and the
-merged model name.
-
-Alternatively you may activate InvokeAI's virtual environment from the
-command line, and call the script via `merge_models --gui` to open up
-a version that has a nice graphical front end. To get the commandline-
-only version, omit `--gui`.
-
-The user interface for the text-based interactive script is
-straightforward. It shows you a series of setting fields. Use control-N (^N)
-to move to the next field, and control-P (^P) to move to the previous
-one. You can also use TAB and shift-TAB to move forward and
-backward. Once you are in a multiple choice field, use the up and down
-cursor arrows to move to your desired selection, and press or
- to select it. Change text fields by typing in them, and adjust
-scrollbars using the left and right arrow keys.
-
-Once you are happy with your settings, press the OK button. Note that
-there may be two pages of settings, depending on the height of your
-screen, and the OK button may be on the second page. Advance past the
-last field of the first page to get to the second page, and reverse
-this to get back.
-
-If the merge runs successfully, it will create a new diffusers model
-under the selected name and register it with InvokeAI.
-
-
diff --git a/docs/features/OTHER.md b/docs/features/OTHER.md
deleted file mode 100644
index 00bfd05e146..00000000000
--- a/docs/features/OTHER.md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-title: Others
----
-
-# :fontawesome-regular-share-from-square: Others
-
-## **Google Colab**
-
-[{ align="right" }](https://colab.research.google.com/github/lstein/stable-diffusion/blob/main/notebooks/Stable_Diffusion_AI_Notebook.ipynb)
-
-Open and follow instructions to use an isolated environment running Dream.
-
-Output Example:
-
-
-
----
-
-## **Invisible Watermark**
-
-In keeping with the principles for responsible AI generation, and to
-help AI researchers avoid synthetic images contaminating their
-training sets, InvokeAI adds an invisible watermark to each of the
-final images it generates. The watermark consists of the text
-"InvokeAI" and can be viewed using the
-[invisible-watermarks](https://github.com/ShieldMnt/invisible-watermark)
-tool.
-
-Watermarking is controlled using the `invisible-watermark` setting in
-`invokeai.yaml`. To turn it off, add the following line under the `Features`
-category.
-
-```
-invisible_watermark: false
-```
-
-
-## **Weighted Prompts**
-
-You may weight different sections of the prompt to tell the sampler to attach different levels of
-priority to them, by adding `:` to the end of the section you wish to up- or downweight. For
-example consider this prompt:
-
-```bash
-(tabby cat):0.25 (white duck):0.75 hybrid
-```
-
-This will tell the sampler to invest 25% of its effort on the tabby cat aspect of the image and 75%
-on the white duck aspect (surprisingly, this example actually works). The prompt weights can use any
-combination of integers and floating point numbers, and they do not need to add up to 1.
-
diff --git a/docs/features/POSTPROCESS.md b/docs/features/POSTPROCESS.md
deleted file mode 100644
index 33a2d194124..00000000000
--- a/docs/features/POSTPROCESS.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-title: Postprocessing
----
-
-# :material-image-edit: Postprocessing
-
-This sections details the ability to improve faces and upscale images.
-
-## Face Fixing
-
-As of InvokeAI 3.0, the easiest way to improve faces created during image generation is through the Inpainting functionality of the Unified Canvas. Simply add the image containing the faces that you would like to improve to the canvas, mask the face to be improved and run the invocation. For best results, make sure to use an inpainting specific model; these are usually identified by the "-inpainting" term in the model name.
-
-## Upscaling
-
-Open the upscaling dialog by clicking on the "expand" icon located
-above the image display area in the Web UI:
-
-
-
-
-
-The default upscaling option is Real-ESRGAN x2 Plus, which will scale your image by a factor of two. This means upscaling a 512x512 image will result in a new 1024x1024 image.
-
-Other options are the x4 upscalers, which will scale your image by a factor of 4.
-
-
-!!! note
-
- Real-ESRGAN is memory intensive. In order to avoid crashes and memory overloads
- during the Stable Diffusion process, these effects are applied after Stable Diffusion has completed
- its work.
-
- In single image generations, you will see the output right away but when you are using multiple
- iterations, the images will first be generated and then upscaled after that
- process is complete. While the image generation is taking place, you will still be able to preview
- the base images.
-
-## How to disable
-
-If, for some reason, you do not wish to load the ESRGAN libraries,
-you can disable them on the invoke.py command line with the `--no_esrgan` options.
diff --git a/docs/features/PROMPTS.md b/docs/features/PROMPTS.md
deleted file mode 100644
index 5eff1aa2a5f..00000000000
--- a/docs/features/PROMPTS.md
+++ /dev/null
@@ -1,274 +0,0 @@
----
-title: Prompting-Features
----
-
-# :octicons-command-palette-24: Prompting-Features
-
-## **Prompt Syntax Features**
-
-The InvokeAI prompting language has the following features:
-
-### Attention weighting
-
-Append a word or phrase with `-` or `+`, or a weight between `0` and `2`
-(`1`=default), to decrease or increase "attention" (= a mix of per-token CFG
-weighting multiplier and, for `-`, a weighted blend with the prompt without the
-term).
-
-The following syntax is recognised:
-
-- single words without parentheses: `a tall thin man picking apricots+`
-- single or multiple words with parentheses:
- `a tall thin man picking (apricots)+` `a tall thin man picking (apricots)-`
- `a tall thin man (picking apricots)+` `a tall thin man (picking apricots)-`
-- more effect with more symbols `a tall thin man (picking apricots)++`
-- nesting `a tall thin man (picking apricots+)++` (`apricots` effectively gets
- `+++`)
-- all of the above with explicit numbers `a tall thin man picking (apricots)1.1`
- `a tall thin man (picking (apricots)1.3)1.1`. (`+` is equivalent to 1.1, `++`
- is pow(1.1,2), `+++` is pow(1.1,3), etc; `-` means 0.9, `--` means pow(0.9,2),
- etc.)
-
-You can use this to increase or decrease the amount of something. Starting from
-this prompt of `a man picking apricots from a tree`, let's see what happens if
-we increase and decrease how much attention we want Stable Diffusion to pay to
-the word `apricots`:
-
-
-
-
-
-
-
-Using `-` to reduce apricot-ness:
-
-| `a man picking apricots- from a tree` | `a man picking apricots-- from a tree` | `a man picking apricots--- from a tree` |
-| ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-|  |  |  |
-
-Using `+` to increase apricot-ness:
-
-| `a man picking apricots+ from a tree` | `a man picking apricots++ from a tree` | `a man picking apricots+++ from a tree` | `a man picking apricots++++ from a tree` | `a man picking apricots+++++ from a tree` |
-| ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-|  |  |  |  |  |
-
-You can also change the balance between different parts of a prompt. For
-example, below is a `mountain man`:
-
-
-
-
-
-
-
-And here he is with more mountain:
-
-| `mountain+ man` | `mountain++ man` | `mountain+++ man` |
-| ---------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- |
-|  |  |  |
-
-Or, alternatively, with more man:
-
-| `mountain man+` | `mountain man++` | `mountain man+++` | `mountain man++++` |
-| ---------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- |
-|  |  |  |  |
-
-### Prompt Blending
-
-- `("a tall thin man picking apricots", "a tall thin man picking pears").blend(1,1)`
-- The existing prompt blending using `:` will continue to be supported -
- `("a tall thin man picking apricots", "a tall thin man picking pears").blend(1,1)`
- is equivalent to
- `a tall thin man picking apricots:1 a tall thin man picking pears:1` in the
- old syntax.
-- Attention weights can be nested inside blends.
-- Non-normalized blends are supported by passing `no_normalize` as an additional
- argument to the blend weights, eg
- `("a tall thin man picking apricots", "a tall thin man picking pears").blend(1,-1,no_normalize)`.
- very fun to explore local maxima in the feature space, but also easy to
- produce garbage output.
-
-See the section below on "Prompt Blending" for more information about how this
-works.
-
-### Prompt Conjunction
-Join multiple clauses together to create a conjoined prompt. Each clause will be passed to CLIP separately.
-
-For example, the prompt:
-
-```bash
-"A mystical valley surround by towering granite cliffs, watercolor, warm"
-```
-
-Can be used with .and():
-```bash
-("A mystical valley", "surround by towering granite cliffs", "watercolor", "warm").and()
-```
-
-Each will give you different results - try them out and see what you prefer!
-
-
-### Escaping parentheses and speech marks
-
-If the model you are using has parentheses () or speech marks "" as part of its
-syntax, you will need to "escape" these using a backslash, so that`(my_keyword)`
-becomes `\(my_keyword\)`. Otherwise, the prompt parser will attempt to interpret
-the parentheses as part of the prompt syntax and it will get confused.
-
----
-
-## **Prompt Blending**
-
-You may blend together prompts to explore the AI's
-latent semantic space and generate interesting (and often surprising!)
-variations. The syntax is:
-
-```bash
-("prompt #1", "prompt #2").blend(0.25, 0.75)
-```
-
-This will tell the sampler to blend 25% of the concept of prompt #1 with 75%
-of the concept of prompt #2. It is recommended to keep the sum of the weights to around 1.0, but interesting things might happen if you go outside of this range.
-
-Because you are exploring the "mind" of the AI, the AI's way of mixing two
-concepts may not match yours, leading to surprising effects. To illustrate, here
-are three images generated using various combinations of blend weights. As
-usual, unless you fix the seed, the prompts will give you different results each
-time you run them.
-
-Let's examine how this affects image generation results:
-
-
-```bash
-"blue sphere, red cube, hybrid"
-```
-
-This example doesn't use blending at all and represents the default way of mixing
-concepts.
-
-
-
-
-
-
-
-It's interesting to see how the AI expressed the concept of "cube" within the sphere. If you look closely, there is depth there, so the enclosing frame is actually a cube.
-
-
-
-```bash
-("blue sphere", "red cube").blend(0.25, 0.75)
-```
-
-
-
-
-
-Now that's interesting. We get an image with a resemblance of a red cube, with a hint of blue shadows which represents a melding of concepts within the AI's "latent space" of semantic representations.
-
-
-
-```bash
-("blue sphere", "red cube").blend(0.75, 0.25)
-```
-
-
-
-
-
-Definitely more blue-spherey.
-
-
-
-```bash
-("blue sphere", "red cube").blend(0.5, 0.5)
-```
-
-
-
-
-
-
-
-Whoa...! I see blue and red, and if I squint, spheres and cubes.
-
-
-
-## Dynamic Prompts
-
-Dynamic Prompts are a powerful feature designed to produce a variety of prompts based on user-defined options. Using a special syntax, you can construct a prompt with multiple possibilities, and the system will automatically generate a series of permutations based on your settings. This is extremely beneficial for ideation, exploring various scenarios, or testing different concepts swiftly and efficiently.
-
-### Structure of a Dynamic Prompt
-
-A Dynamic Prompt comprises of regular text, supplemented with alternatives enclosed within curly braces {} and separated by a vertical bar |. For example: {option1|option2|option3}. The system will then select one of the options to include in the final prompt. This flexible system allows for options to be placed throughout the text as needed.
-
-Furthermore, Dynamic Prompts can designate multiple selections from a single group of options. This feature is triggered by prefixing the options with a numerical value followed by $$. For example, in {2$$option1|option2|option3}, the system will select two distinct options from the set.
-### Creating Dynamic Prompts
-
-To create a Dynamic Prompt, follow these steps:
-
- Draft your sentence or phrase, identifying words or phrases with multiple possible options.
- Encapsulate the different options within curly braces {}.
- Within the braces, separate each option using a vertical bar |.
- If you want to include multiple options from a single group, prefix with the desired number and $$.
-
-For instance: A {house|apartment|lodge|cottage} in {summer|winter|autumn|spring} designed in {style1|style2|style3}.
-### How Dynamic Prompts Work
-
-Once a Dynamic Prompt is configured, the system generates an array of combinations using the options provided. Each group of options in curly braces is treated independently, with the system selecting one option from each group. For a prefixed set (e.g., 2$$), the system will select two distinct options.
-
-For example, the following prompts could be generated from the above Dynamic Prompt:
-
- A house in summer designed in style1, style2
- A lodge in autumn designed in style3, style1
- A cottage in winter designed in style2, style3
- And many more!
-
-When the `Combinatorial` setting is on, Invoke will disable the "Images" selection, and generate every combination up until the setting for Max Prompts is reached.
-When the `Combinatorial` setting is off, Invoke will randomly generate combinations up until the setting for Images has been reached.
-
-
-
-### Tips and Tricks for Using Dynamic Prompts
-
-Below are some useful strategies for creating Dynamic Prompts:
-
- Utilize Dynamic Prompts to generate a wide spectrum of prompts, perfect for brainstorming and exploring diverse ideas.
- Ensure that the options within a group are contextually relevant to the part of the sentence where they are used. For instance, group building types together, and seasons together.
- Apply the 2$$ prefix when you want to incorporate more than one option from a single group. This becomes quite handy when mixing and matching different elements.
- Experiment with different quantities for the prefix. For example, 3$$ will select three distinct options.
- Be aware of coherence in your prompts. Although the system can generate all possible combinations, not all may semantically make sense. Therefore, carefully choose the options for each group.
- Always review and fine-tune the generated prompts as needed. While Dynamic Prompts can help you generate a multitude of combinations, the final polishing and refining remain in your hands.
-
-
-## SDXL Prompting
-
-Prompting with SDXL is slightly different than prompting with SD1.5 or SD2.1 models - SDXL expects a prompt _and_ a style.
-
-
-### Prompting
-
-
-
-
-
-
-In the prompt box, enter a positive or negative prompt as you normally would.
-
-For the style box you can enter a style that you want the image to be generated in. You can use styles from this example list, or any other style you wish: anime, photographic, digital art, comic book, fantasy art, analog film, neon punk, isometric, low poly, origami, line art, cinematic, 3d model, pixel art, etc.
-
-
-### Concatenated Prompts
-
-
-InvokeAI also has the option to concatenate the prompt and style inputs, by pressing the "link" button in the Positive Prompt box.
-
-This concatenates the prompt & style inputs, and passes the joined prompt and style to the SDXL model.
-
-
-
-
-
-
-
-
diff --git a/docs/features/TEXTUAL_INVERSIONS.md b/docs/features/TEXTUAL_INVERSIONS.md
deleted file mode 100644
index a3ede80d1fd..00000000000
--- a/docs/features/TEXTUAL_INVERSIONS.md
+++ /dev/null
@@ -1,55 +0,0 @@
-## Using Textual Inversion Files
-
-Textual inversion (TI) files are small models that customize the output of
-Stable Diffusion image generation. They can augment SD with specialized subjects
-and artistic styles. They are also known as "embeds" in the machine learning
-world.
-
-Each TI file introduces one or more vocabulary terms to the SD model. These are
-known in InvokeAI as "triggers." Triggers are denoted using angle brackets
-as in "<trigger-phrase>". The two most common type of
-TI files that you'll encounter are `.pt` and `.bin` files, which are produced by
-different TI training packages. InvokeAI supports both formats, but its
-[built-in TI training system](TRAINING.md) produces `.pt`.
-
-[Hugging Face](https://huggingface.co/sd-concepts-library) has
-amassed a large library of >800 community-contributed TI files covering a
-broad range of subjects and styles. You can also install your own or others' TI files
-by placing them in the designated directory for the compatible model type
-
-### An Example
-
-Here are a few examples to illustrate how it works. All these images
-were generated using the legacy command-line client and the Stable
-Diffusion 1.5 model:
-
-| Japanese gardener | Japanese gardener <ghibli-face> | Japanese gardener <hoi4-leaders> | Japanese gardener <cartoona-animals> |
-| :--------------------------------: | :-----------------------------------: | :------------------------------------: | :----------------------------------------: |
-|  |  |  |  |
-
-You can also combine styles and concepts:
-
-
- | A portrait of <alf> in <cartoona-animal> style |
- | :--------------------------------------------------------: |
- |  |
-
-
-
-## Installing your Own TI Files
-
-You may install any number of `.pt` and `.bin` files simply by copying them into
-the `embedding` directory of the corresponding InvokeAI models directory (usually `invokeai`
-in your home directory). For example, you can simply move a Stable Diffusion 1.5 embedding file to
-the `sd-1/embedding` folder. Be careful not to overwrite one file with another.
-For example, TI files generated by the Hugging Face toolkit share the named
-`learned_embedding.bin`. You can rename these, or use subdirectories to keep them distinct.
-
-At startup time, InvokeAI will scan the various `embedding` directories and load any TI
-files it finds there for compatible models. At startup you will see a message similar to this one:
-
-```bash
->> Current embedding manager terms: ,
-```
-To use these when generating, simply type the `<` key in your prompt to open the Textual Inversion WebUI and
-select the embedding you'd like to use. This UI has type-ahead support, so you can easily find supported embeddings.
\ No newline at end of file
diff --git a/docs/features/TRAINING.md b/docs/features/TRAINING.md
deleted file mode 100644
index 47f8557889e..00000000000
--- a/docs/features/TRAINING.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-title: Training
----
-
-# :material-file-document: Training
-
-Invoke Training has moved to its own repository, with a dedicated UI for accessing common scripts like Textual Inversion and LoRA training.
-
-You can find more by visiting the repo at https://github.com/invoke-ai/invoke-training
diff --git a/docs/features/UNIFIED_CANVAS.md b/docs/features/UNIFIED_CANVAS.md
deleted file mode 100644
index 476d2009be2..00000000000
--- a/docs/features/UNIFIED_CANVAS.md
+++ /dev/null
@@ -1,283 +0,0 @@
----
-title: Unified Canvas
----
-
-The Unified Canvas is a tool designed to streamline and simplify the process of
-composing an image using Stable Diffusion. It offers artists all of the
-available Stable Diffusion generation modes (Text To Image, Image To Image,
-Inpainting, and Outpainting) as a single unified workflow. The flexibility of
-the tool allows you to tweak and edit image generations, extend images beyond
-their initial size, and to create new content in a freeform way both inside and
-outside of existing images.
-
-This document explains the basics of using the Unified Canvas, introducing you
-to its features and tools one by one. It also describes some of the more
-advanced tools available to power users of the Canvas.
-
-## Basics
-
-The Unified Canvas consists of two layers: the **Base Layer** and the **Mask
-Layer**. You can swap from one layer to the other by selecting the layer you
-want in the drop-down menu on the top left corner of the Unified Canvas, or by
-pressing the (Q) hotkey.
-
-### Base Layer
-
-The **Base Layer** is the image content currently managed by the Canvas, and can
-be exported at any time to the gallery by using the **Save to Gallery** option.
-When the Base Layer is selected, the Brush (B) and Eraser (E) tools will
-directly manipulate the base layer. Any images uploaded to the Canvas, or sent
-to the Unified Canvas from the gallery, will clear out all existing content and
-set the Base layer to the new image.
-
-### Staging Area
-
-When you generate images, they will display in the Canvas's **Staging Area**,
-alongside the Staging Area toolbar buttons. While the Staging Area is active,
-you cannot interact with the Canvas itself.
-
-
-
-
-
-
-
-Accepting generations will commit the new generation to the **Base Layer**. You
-can review all generated images using the Prev/Next arrows, save any individual
-generations to your gallery (without committing to the Base layer) or discard
-generations. While you can Undo a discard in an individual Canvas session, any
-generations that are not saved will be lost when the Canvas resets.
-
-### Mask Layer
-
-The **Mask Layer** consists of any masked sections that have been created to
-inform Inpainting generations. You can paint a new mask, or edit an existing
-mask, using the Brush tool and the Eraser with the Mask layer set as your Active
-layer. Any masked areas will only affect generation inside of the current
-bounding box.
-
-### Bounding Box
-
-When generating a new image, Invoke will process and apply new images within the
-area denoted by the **Bounding Box**. The Width & Height settings of the
-Bounding Box, as well as its location within the Unified Canvas and pixels or
-empty space that it encloses, determine how new invocations are generated - see
-[Inpainting & Outpainting](#inpainting-and-outpainting) below. The Bounding Box
-can be moved and resized using the Move (V) tool. It can also be resized using
-the Bounding Box options in the Options Panel. By using these controls you can
-generate larger or smaller images, control which sections of the image are being
-processed, as well as control Bounding Box tools like the Bounding Box
-fill/erase.
-
-### Inpainting & Outpainting
-
-"Inpainting" means asking the AI to refine part of an image while leaving the
-rest alone. For example, updating a portrait of your grandmother to have her
-wear a biker's jacket.
-
-| masked original | inpaint result |
-| :-------------------------------------------------------------: | :----------------------------------------------------------------------------------------: |
-|  |  |
-
-"Outpainting" means asking the AI to expand the original image beyond its
-original borders, making a bigger image that's still based on the original. For
-example, extending the above image of your Grandmother in a biker's jacket to
-include her wearing jeans (and while we're at it, a motorcycle!)
-
-
-
-
-
-
-
-When you are using the Unified Canvas, Invoke decides automatically whether to
-do Inpainting, Outpainting, ImageToImage, or TextToImage by looking inside the
-area enclosed by the Bounding Box. It chooses the appropriate type of generation
-based on whether the Bounding Box contains empty (transparent) areas on the Base
-layer, or whether it contains colored areas from previous generations (or from
-painted brushstrokes) on the Base layer, and/or whether the Mask layer contains
-any brushstrokes. See [Generation Methods](#generation-methods) below for more
-information.
-
-## Getting Started
-
-To get started with the Unified Canvas, you will want to generate a new base
-layer using Txt2Img or importing an initial image. We'll refer to either of
-these methods as the "initial image" in the below guide.
-
-From there, you can consider the following techniques to augment your image:
-
-- **New Images**: Move the bounding box to an empty area of the Canvas, type in
- your prompt, and Invoke, to generate a new image using the Text to Image
- function.
-- **Image Correction**: Use the color picker and brush tool to paint corrections
- on the image, switch to the Mask layer, and brush a mask over your painted
- area to use **Inpainting**. You can also use the **ImageToImage** generation
- method to invoke new interpretations of the image.
-- **Image Expansion**: Move the bounding box to include a portion of your
- initial image, and a portion of transparent/empty pixels, then Invoke using a
- prompt that describes what you'd like to see in that area. This will Outpaint
- the image. You'll typically find more coherent results if you keep about
- 50-60% of the original image in the bounding box. Make sure that the Image To
- Image Strength slider is set to a high value - you may need to set it higher
- than you are used to.
-- **New Content on Existing Images**: If you want to add new details or objects
- into your image, use the brush tool to paint a sketch of what you'd like to
- see on the image, switch to the Mask layer, and brush a mask over your painted
- area to use **Inpainting**. If the masked area is small, consider using a
- smaller bounding box to take advantage of Invoke's automatic Scaling features,
- which can help to produce better details.
-- **And more**: There are a number of creative ways to use the Canvas, and the
- above are just starting points. We're excited to see what you come up with!
-
-## Generation Methods
-
-The Canvas can use all generation methods available (Txt2Img, Img2Img,
-Inpainting, and Outpainting), and these will be automatically selected and used
-based on the current selection area within the Bounding Box.
-
-### Text to Image
-
-If the Bounding Box is placed over an area of Canvas with an **empty Base
-Layer**, invoking a new image will use **TextToImage**. This generates an
-entirely new image based on your prompt.
-
-### Image to Image
-
-If the Bounding Box is placed over an area of Canvas with an **existing Base
-Layer area with no transparent pixels or masks**, invoking a new image will use
-**ImageToImage**. This uses the image within the bounding box and your prompt to
-interpret a new image. The image will be closer to your original image at lower
-Image to Image strengths.
-
-### Inpainting
-
-If the Bounding Box is placed over an area of Canvas with an **existing Base
-Layer and any pixels selected using the Mask layer**, invoking a new image will
-use **Inpainting**. Inpainting uses the existing colors/forms in the masked area
-in order to generate a new image for the masked area only. The unmasked portion
-of the image will remain the same. Image to Image strength applies to the
-inpainted area.
-
-If you desire something completely different from the original image in your new
-generation (i.e., if you want Invoke to ignore existing colors/forms), consider
-toggling the Inpaint Replace setting on, and use high values for both Inpaint
-Replace and Image To Image Strength.
-
-!!! note
-
- By default, the **Scale Before Processing** option — which
- inpaints more coherent details by generating at a larger resolution and then
- scaling — is only activated when the Bounding Box is relatively small.
- To get the best inpainting results you should therefore resize your Bounding
- Box to the smallest area that contains your mask and enough surrounding detail
- to help Stable Diffusion understand the context of what you want it to draw.
- You should also update your prompt so that it describes _just_ the area within
- the Bounding Box.
-
-### Outpainting
-
-If the Bounding Box is placed over an area of Canvas partially filled by an
-existing Base Layer area and partially by transparent pixels or masks, invoking
-a new image will use **Outpainting**, as well as **Inpainting** any masked
-areas.
-
----
-
-## Advanced Features
-
-Features with non-obvious behavior are detailed below, in order to provide
-clarity on the intent and common use cases we expect for utilizing them.
-
-### Toolbar
-
-#### Mask Options
-
-- **Enable Mask** - This flag can be used to Enable or Disable the currently
- painted mask. If you have painted a mask, but you don't want it affect the
- next invocation, but you _also_ don't want to delete it, then you can set this
- option to Disable. When you want the mask back, set this back to Enable.
-- **Preserve Masked Area** - When enabled, Preserve Masked Area inverts the
- effect of the Mask on the Inpainting process. Pixels in masked areas will be
- kept unchanged, and unmasked areas will be regenerated.
-
-#### Creative Tools
-
-- **Brush - Base/Mask Modes** - The Brush tool switches automatically between
- different modes of operation for the Base and Mask layers respectively.
- - On the Base layer, the brush will directly paint on the Canvas using the
- color selected on the Brush Options menu.
- - On the Mask layer, the brush will create a new mask. If you're finding the
- mask difficult to see over the existing content of the Unified Canvas, you
- can change the color it is drawn with using the color selector on the Mask
- Options dropdown.
-- **Erase Bounding Box** - On the Base layer, erases all pixels within the
- Bounding Box.
-- **Fill Bounding Box** - On the Base layer, fills all pixels within the
- Bounding Box with the currently selected color.
-
-#### Canvas Tools
-
-- **Move Tool** - Allows for manipulation of the Canvas view (by dragging on the
- Canvas, outside the bounding box), the Bounding Box (by dragging the edges of
- the box), or the Width/Height of the Bounding Box (by dragging one of the 9
- directional handles).
-- **Reset View** - Click to re-orients the view to the center of the Bounding
- Box.
-- **Merge Visible** - If your browser is having performance problems drawing the
- image in the Unified Canvas, click this to consolidate all of the information
- currently being rendered by your browser into a merged copy of the image. This
- lowers the resource requirements and should improve performance.
-
-### Compositing / Seam Correction
-
-When doing Inpainting or Outpainting, Invoke needs to merge the pixels generated
-by Stable Diffusion into your existing image. This is achieved through compositing - the area around the the boundary between your image and the new generation is
-automatically blended to produce a seamless output. In a fully automatic
-process, a mask is generated to cover the boundary, and then the area of the boundary is
-Inpainted.
-
-Although the default options should work well most of the time, sometimes it can
-help to alter the parameters that control the Compositing. A larger blur and
-a blur setting have been noted as producing
-consistently strong results . Strength of 0.7 is best for reducing hard seams.
-
-- **Mode** - What part of the image will have the the Compositing applied to it.
- - **Mask edge** will apply Compositing to the edge of the masked area
- - **Mask** will apply Compositing to the entire masked area
- - **Unmasked** will apply Compositing to the entire image
-- **Steps** - Number of generation steps that will occur during the Coherence Pass, similar to Denoising Steps. Higher step counts will generally have better results.
-- **Strength** - How much noise is added for the Coherence Pass, similar to Denoising Strength. A strength of 0 will result in an unchanged image, while a strength of 1 will result in an image with a completely new area as defined by the Mode setting.
-- **Blur** - Adjusts the pixel radius of the the mask. A larger blur radius will cause the mask to extend past the visibly masked area, while too small of a blur radius will result in a mask that is smaller than the visibly masked area.
-- **Blur Method** - The method of blur applied to the masked area.
-
-
-### Infill & Scaling
-
-- **Scale Before Processing & W/H**: When generating images with a bounding box
- smaller than the optimized W/H of the model (e.g., 512x512 for SD1.5), this
- feature first generates at a larger size with the same aspect ratio, and then
- scales that image down to fill the selected area. This is particularly useful
- when inpainting very small details. Scaling is optional but is enabled by
- default.
-- **Inpaint Replace**: When Inpainting, the default method is to utilize the
- existing RGB values of the Base layer to inform the generation process. If
- Inpaint Replace is enabled, noise is generated and blended with the existing
- pixels (completely replacing the original RGB values at an Inpaint Replace
- value of 1). This can help generate more variation from the pixels on the Base
- layers.
- - When using Inpaint Replace you should use a higher Image To Image Strength
- value, especially at higher Inpaint Replace values
-- **Infill Method**: Invoke currently supports two methods for producing RGB
- values for use in the Outpainting process: Patchmatch and Tile. We believe
- that Patchmatch is the superior method, however we provide support for Tile in
- case Patchmatch cannot be installed or is unavailable on your computer.
-- **Tile Size**: The Tile method for Outpainting sources small portions of the
- original image and randomly place these into the areas being Outpainted. This
- value sets the size of those tiles.
-
-## Hot Keys
-
-The Unified Canvas is a tool that excels when you use hotkeys. You can view the
-full list of keyboard shortcuts, updated with all new features, by clicking the
-Keyboard Shortcuts icon at the top right of the InvokeAI WebUI.
diff --git a/docs/features/UTILITIES.md b/docs/features/UTILITIES.md
deleted file mode 100644
index 2d62fe3a79f..00000000000
--- a/docs/features/UTILITIES.md
+++ /dev/null
@@ -1,336 +0,0 @@
----
-title: Command-line Utilities
----
-
-# :material-file-document: Utilities
-
-# Command-line Utilities
-
-InvokeAI comes with several scripts that are accessible via the
-command line. To access these commands, start the "developer's
-console" from the launcher (`invoke.bat` menu item [7]). Users who are
-familiar with Python can alternatively activate InvokeAI's virtual
-environment (typically, but not necessarily `invokeai/.venv`).
-
-In the developer's console, type the script's name to run it. To get a
-synopsis of what a utility does and the command-line arguments it
-accepts, pass it the `-h` argument, e.g.
-
-```bash
-invokeai-merge -h
-```
-## **invokeai-web**
-
-This script launches the web server and is effectively identical to
-selecting option [1] in the launcher. An advantage of launching the
-server from the command line is that you can override any setting
-configuration option in `invokeai.yaml` using like-named command-line
-arguments. For example, to temporarily change the size of the RAM
-cache to 7 GB, you can launch as follows:
-
-```bash
-invokeai-web --ram 7
-```
-
-## **invokeai-merge**
-
-This is the model merge script, the same as launcher option [3]. Call
-it with the `--gui` command-line argument to start the interactive
-console-based GUI. Alternatively, you can run it non-interactively
-using command-line arguments as illustrated in the example below which
-merges models named `stable-diffusion-1.5` and `inkdiffusion` into a new model named
-`my_new_model`:
-
-```bash
-invokeai-merge --force --base-model sd-1 --models stable-diffusion-1.5 inkdiffusion --merged_model_name my_new_model
-```
-
-## **invokeai-ti**
-
-This is the textual inversion training script that is run by launcher
-option [2]. Call it with `--gui` to run the interactive console-based
-front end. It can also be run non-interactively. It has about a
-zillion arguments, but a typical training session can be launched
-with:
-
-```bash
-invokeai-ti --model stable-diffusion-1.5 \
- --placeholder_token 'jello' \
- --learnable_property object \
- --num_train_epochs 50 \
- --train_data_dir /path/to/training/images \
- --output_dir /path/to/trained/model
-```
-
-(Note that \\ is the Linux/Mac long-line continuation character. Use ^
-in Windows).
-
-## **invokeai-install**
-
-This is the console-based model install script that is run by launcher
-option [4]. If called without arguments, it will launch the
-interactive console-based interface. It can also be used
-non-interactively to list, add and remove models as shown by these
-examples:
-
-* This will download and install three models from CivitAI, HuggingFace,
-and local disk:
-
-```bash
-invokeai-install --add https://civitai.com/api/download/models/161302 ^
- gsdf/Counterfeit-V3.0 ^
- D:\Models\merge_model_two.safetensors
-```
-(Note that ^ is the Windows long-line continuation character. Use \\ on
-Linux/Mac).
-
-* This will list installed models of type `main`:
-
-```bash
-invokeai-model-install --list-models main
-```
-
-* This will delete the models named `voxel-ish` and `realisticVision`:
-
-```bash
-invokeai-model-install --delete voxel-ish realisticVision
-```
-
-## **invokeai-configure**
-
-This is the console-based configure script that ran when InvokeAI was
-first installed. You can run it again at any time to change the
-configuration, repair a broken install.
-
-Called without any arguments, `invokeai-configure` enters interactive
-mode with two screens. The first screen is a form that provides access
-to most of InvokeAI's configuration options. The second screen lets
-you download, add, and delete models interactively. When you exit the
-second screen, the script will add any missing "support models"
-needed for core functionality, and any selected "sd weights" which are
-the model checkpoint/diffusers files.
-
-This behavior can be changed via a series of command-line
-arguments. Here are some of the useful ones:
-
-* `invokeai-configure --skip-sd-weights --skip-support-models`
-This will run just the configuration part of the utility, skipping
-downloading of support models and stable diffusion weights.
-
-* `invokeai-configure --yes`
-This will run the configure script non-interactively. It will set the
-configuration options to their default values, install/repair support
-models, and download the "recommended" set of SD models.
-
-* `invokeai-configure --yes --default_only`
-This will run the configure script non-interactively. In contrast to
-the previous command, it will only download the default SD model,
-Stable Diffusion v1.5
-
-* `invokeai-configure --yes --default_only --skip-sd-weights`
-This is similar to the previous command, but will not download any
-SD models at all. It is usually used to repair a broken install.
-
-By default, `invokeai-configure` runs on the currently active InvokeAI
-root folder. To run it against a different root, pass it the `--root
- ` argument.
-
-Lastly, you can use `invokeai-configure` to create a working root
-directory entirely from scratch. Assuming you wish to make a root directory
-named `InvokeAI-New`, run this command:
-
-```bash
-invokeai-configure --root InvokeAI-New --yes --default_only
-```
-This will create a minimally functional root directory. You can now
-launch the web server against it with `invokeai-web --root InvokeAI-New`.
-
-## **invokeai-update**
-
-This is the interactive console-based script that is run by launcher
-menu item [8] to update to a new version of InvokeAI. It takes no
-command-line arguments.
-
-## **invokeai-metadata**
-
-This is a script which takes a list of InvokeAI-generated images and
-outputs their metadata in the same JSON format that you get from the
-`>` button in the Web GUI. For example:
-
-```bash
-$ invokeai-metadata ffe2a115-b492-493c-afff-7679aa034b50.png
-ffe2a115-b492-493c-afff-7679aa034b50.png:
-{
- "app_version": "3.1.0",
- "cfg_scale": 8.0,
- "clip_skip": 0,
- "controlnets": [],
- "generation_mode": "sdxl_txt2img",
- "height": 1024,
- "loras": [],
- "model": {
- "base_model": "sdxl",
- "model_name": "stable-diffusion-xl-base-1.0",
- "model_type": "main"
- },
- "negative_prompt": "",
- "negative_style_prompt": "",
- "positive_prompt": "military grade sushi dinner for shock troopers",
- "positive_style_prompt": "",
- "rand_device": "cpu",
- "refiner_cfg_scale": 7.5,
- "refiner_model": {
- "base_model": "sdxl-refiner",
- "model_name": "sd_xl_refiner_1.0",
- "model_type": "main"
- },
- "refiner_negative_aesthetic_score": 2.5,
- "refiner_positive_aesthetic_score": 6.0,
- "refiner_scheduler": "euler",
- "refiner_start": 0.8,
- "refiner_steps": 20,
- "scheduler": "euler",
- "seed": 387129902,
- "steps": 25,
- "width": 1024
-}
-```
-
-You may list multiple files on the command line.
-
-## **invokeai-import-images**
-
-InvokeAI uses a database to store information about images it
-generated, and just copying the image files from one InvokeAI root
-directory to another does not automatically import those images into
-the destination's gallery. This script allows you to bulk import
-images generated by one instance of InvokeAI into a gallery maintained
-by another. It also works on images generated by older versions of
-InvokeAI, going way back to version 1.
-
-This script has an interactive mode only. The following example shows
-it in action:
-
-```bash
-$ invokeai-import-images
-===============================================================================
-This script will import images generated by earlier versions of
-InvokeAI into the currently installed root directory:
- /home/XXXX/invokeai-main
-If this is not what you want to do, type ctrl-C now to cancel.
-===============================================================================
-= Configuration & Settings
-Found invokeai.yaml file at /home/XXXX/invokeai-main/invokeai.yaml:
- Database : /home/XXXX/invokeai-main/databases/invokeai.db
- Outputs : /home/XXXX/invokeai-main/outputs/images
-
-Use these paths for import (yes) or choose different ones (no) [Yn]:
-Inputs: Specify absolute path containing InvokeAI .png images to import: /home/XXXX/invokeai-2.3/outputs/images/
-Include files from subfolders recursively [yN]?
-
-Options for board selection for imported images:
-1) Select an existing board name. (found 4)
-2) Specify a board name to create/add to.
-3) Create/add to board named 'IMPORT'.
-4) Create/add to board named 'IMPORT' with the current datetime string appended (.e.g IMPORT_20230919T203519Z).
-5) Create/add to board named 'IMPORT' with a the original file app_version appended (.e.g IMPORT_2.2.5).
-Specify desired board option: 3
-
-===============================================================================
-= Import Settings Confirmation
-
-Database File Path : /home/XXXX/invokeai-main/databases/invokeai.db
-Outputs/Images Directory : /home/XXXX/invokeai-main/outputs/images
-Import Image Source Directory : /home/XXXX/invokeai-2.3/outputs/images/
- Recurse Source SubDirectories : No
-Count of .png file(s) found : 5785
-Board name option specified : IMPORT
-Database backup will be taken at : /home/XXXX/invokeai-main/databases/backup
-
-Notes about the import process:
-- Source image files will not be modified, only copied to the outputs directory.
-- If the same file name already exists in the destination, the file will be skipped.
-- If the same file name already has a record in the database, the file will be skipped.
-- Invoke AI metadata tags will be updated/written into the imported copy only.
-- On the imported copy, only Invoke AI known tags (latest and legacy) will be retained (dream, sd-metadata, invokeai, invokeai_metadata)
-- A property 'imported_app_version' will be added to metadata that can be viewed in the UI's metadata viewer.
-- The new 3.x InvokeAI outputs folder structure is flat so recursively found source imges will all be placed into the single outputs/images folder.
-
-Do you wish to continue with the import [Yn] ?
-
-Making DB Backup at /home/lstein/invokeai-main/databases/backup/backup-20230919T203519Z-invokeai.db...Done!
-
-===============================================================================
-Importing /home/XXXX/invokeai-2.3/outputs/images/17d09907-297d-4db3-a18a-60b337feac66.png
-... (5785 more lines) ...
-===============================================================================
-= Import Complete - Elpased Time: 0.28 second(s)
-
-Source File(s) : 5785
-Total Imported : 5783
-Skipped b/c file already exists on disk : 1
-Skipped b/c file already exists in db : 0
-Errors during import : 1
-```
-## **invokeai-db-maintenance**
-
-This script helps maintain the integrity of your InvokeAI database by
-finding and fixing three problems that can arise over time:
-
-1. An image was manually deleted from the outputs directory, leaving a
- dangling image record in the InvokeAI database. This will cause a
- black image to appear in the gallery. This is an "orphaned database
- image record." The script can fix this by running a "clean"
- operation on the database, removing the orphaned entries.
-
-2. An image is present in the outputs directory but there is no
- corresponding entry in the database. This can happen when the image
- is added manually to the outputs directory, or if a crash occurred
- after the image was generated but before the database was
- completely updated. The symptom is that the image is present in the
- outputs folder but doesn't appear in the InvokeAI gallery. This is
- called an "orphaned image file." The script can fix this problem by
- running an "archive" operation in which orphaned files are moved
- into a directory named `outputs/images-archive`. If you wish, you
- can then run `invokeai-image-import` to reimport these images back
- into the database.
-
-3. The thumbnail for an image is missing, again causing a black
- gallery thumbnail. This is fixed by running the "thumbnaiils"
- operation, which simply regenerates and re-registers the missing
- thumbnail.
-
-You can find and fix all three of these problems in a single go by
-executing this command:
-
-```bash
-invokeai-db-maintenance --operation all
-```
-
-Or you can run just the clean and thumbnail operations like this:
-
-```bash
-invokeai-db-maintenance -operation clean, thumbnail
-```
-
-If called without any arguments, the script will ask you which
-operations you wish to perform.
-
-## **invokeai-migrate3**
-
-This script will migrate settings and models (but not images!) from an
-InvokeAI v2.3 root folder to an InvokeAI 3.X folder. Call it with the
-source and destination root folders like this:
-
-```bash
-invokeai-migrate3 --from ~/invokeai-2.3 --to invokeai-3.1.1
-```
-
-Both directories must previously have been properly created and
-initialized by `invokeai-configure`. If you wish to migrate the images
-contained in the older root as well, you can use the
-`invokeai-image-migrate` script described earlier.
-
----
-
-Copyright (c) 2023, Lincoln Stein and the InvokeAI Development Team
diff --git a/docs/features/WATERMARK+NSFW.md b/docs/features/WATERMARK+NSFW.md
deleted file mode 100644
index c837d66b57b..00000000000
--- a/docs/features/WATERMARK+NSFW.md
+++ /dev/null
@@ -1,96 +0,0 @@
----
-title: Watermarking, NSFW Image Checking
----
-
-# :material-image-off: Invisible Watermark and the NSFW Checker
-
-## Watermarking
-
-InvokeAI does not apply watermarking to images by default. However,
-many computer scientists working in the field of generative AI worry
-that a flood of computer-generated imagery will contaminate the image
-data sets needed to train future generations of generative models.
-
-InvokeAI offers an optional watermarking mode that writes a small bit
-of text, **InvokeAI**, into each image that it generates using an
-"invisible" watermarking library that spreads the information
-throughout the image in a way that is not perceptible to the human
-eye. If you are planning to share your generated images on
-internet-accessible services, we encourage you to activate the
-invisible watermark mode in order to help preserve the digital image
-environment.
-
-The downside of watermarking is that it increases the size of the
-image moderately, and has been reported by some individuals to degrade
-image quality. Your mileage may vary.
-
-To read the watermark in an image, activate the InvokeAI virtual
-environment (called the "developer's console" in the launcher) and run
-the command:
-
-```
-invisible-watermark -a decode -t bytes -m dwtDct -l 64 /path/to/image.png
-```
-
-## The NSFW ("Safety") Checker
-
-Stable Diffusion 1.5-based image generation models will produce sexual
-imagery if deliberately prompted, and will occasionally produce such
-images when this is not intended. Such images are colloquially known
-as "Not Safe for Work" (NSFW). This behavior is due to the nature of
-the training set that Stable Diffusion was trained on, which culled
-millions of "aesthetic" images from the Internet.
-
-You may not wish to be exposed to these images, and in some
-jurisdictions it may be illegal to publicly distribute such imagery,
-including mounting a publicly-available server that provides
-unfiltered images to the public. Furthermore, the [Stable Diffusion
-weights
-License](https://github.com/invoke-ai/InvokeAI/blob/main/LICENSE-SD1+SD2.txt),
-and the [Stable Diffusion XL
-License][https://github.com/invoke-ai/InvokeAI/blob/main/LICENSE-SDXL.txt]
-both forbid the models from being used to "exploit any of the
-vulnerabilities of a specific group of persons."
-
-For these reasons Stable Diffusion offers a "safety checker," a
-machine learning model trained to recognize potentially disturbing
-imagery. When a potentially NSFW image is detected, the checker will
-blur the image and paste a warning icon on top. The checker can be
-turned on and off in the Web interface under Settings.
-
-## Caveats
-
-There are a number of caveats that you need to be aware of.
-
-### Accuracy
-
-The checker is [not perfect](https://arxiv.org/abs/2210.04610).It will
-occasionally flag innocuous images (false positives), and will
-frequently miss violent and gory imagery (false negatives). It rarely
-fails to flag sexual imagery, but this has been known to happen. For
-these reasons, the InvokeAI team prefers to refer to the software as a
-"NSFW Checker" rather than "safety checker."
-
-### Memory Usage and Performance
-
-The NSFW checker consumes an additional 1.2G of GPU VRAM on top of the
-3.4G of VRAM used by Stable Diffusion v1.5 (this is with
-half-precision arithmetic). This means that the checker will not run
-successfully on GPU cards with less than 6GB VRAM, and will reduce the
-size of the images that you can produce.
-
-The checker also introduces a slight performance penalty. Images will
-take ~1 second longer to generate when the checker is
-activated. Generally this is not noticeable.
-
-### Intermediate Images in the Web UI
-
-The checker only operates on the final image produced by the Stable
-Diffusion algorithm. If you are using the Web UI and have enabled the
-display of intermediate images, you will briefly be exposed to a
-low-resolution (mosaicized) version of the final image before it is
-flagged by the checker and replaced by a fully blurred version. You
-are encouraged to turn **off** intermediate image rendering when you
-are using the checker. Future versions of InvokeAI will apply
-additional blurring to intermediate images when the checker is active.
-
diff --git a/docs/features/WEB.md b/docs/features/WEB.md
deleted file mode 100644
index 15a489b932b..00000000000
--- a/docs/features/WEB.md
+++ /dev/null
@@ -1,325 +0,0 @@
----
-title: InvokeAI Web Server
----
-
-# :material-web: InvokeAI Web Server
-
-## Quick guided walkthrough of the WebUI's features
-
-While most of the WebUI's features are intuitive, here is a guided walkthrough
-through its various components.
-
-### Launching the WebUI
-
-To run the InvokeAI web server, start the `invoke.sh`/`invoke.bat`
-script and select option (1). Alternatively, with the InvokeAI
-environment active, run `invokeai-web`:
-
-```bash
-invokeai-web
-```
-
-You can then connect to the server by pointing your web browser at
-http://localhost:9090. To reach the server from a different machine on your LAN,
-you may launch the web server with the `--host` argument and either the IP
-address of the host you are running it on, or the wildcard `0.0.0.0`. For
-example:
-
-```bash
-invoke.sh --host 0.0.0.0
-```
-
-or
-
-```bash
-invokeai-web --host 0.0.0.0
-```
-
-### The InvokeAI Web Interface
-
-{:width="640px"}
-
-The screenshot above shows the Text to Image tab of the WebUI. There are three
-main sections:
-
-1. A **control panel** on the left, which contains various settings
- for text to image generation. The most important part is the text
- field (currently showing `fantasy painting, horned demon`) for
- entering the positive text prompt, another text field right below it for an
- optional negative text prompt (concepts to exclude), and a _Invoke_ button
- to begin the image rendering process.
-
-2. The **current image** section in the middle, which shows a large
- format version of the image you are currently working on. A series
- of buttons at the top lets you modify and manipulate the image in
- various ways.
-
-3. A **gallery** section on the right that contains a history of the images you
- have generated. These images are read and written to the directory specified
- in the `INVOKEAIROOT/invokeai.yaml` initialization file, usually a directory
- named `outputs` in `INVOKEAIROOT`.
-
-In addition to these three elements, there are a series of icons for changing
-global settings, reporting bugs, and changing the theme on the upper right.
-
-There are also a series of icons to the left of the control panel (see
-highlighted area in the screenshot below) which select among a series of tabs
-for performing different types of operations.
-
-
-{:width="512px"}
-
-
-From top to bottom, these are:
-
-1. Text to Image - generate images from text
-2. Image to Image - from an uploaded starting image (drawing or photograph)
- generate a new one, modified by the text prompt
-3. Unified Canvas - Interactively combine multiple images, extend them
- with outpainting,and modify interior portions of the image with
- inpainting, erase portions of a starting image and have the AI fill in
- the erased region from a text prompt.
-4. Node Editor - (experimental) this panel allows you to create
- pipelines of common operations and combine them into workflows.
-5. Model Manager - this panel allows you to import and configure new
- models using URLs, local paths, or HuggingFace diffusers repo_ids.
-
-## Walkthrough
-
-The following walkthrough will exercise most (but not all) of the WebUI's
-feature set.
-
-### Text to Image
-
-1. Launch the WebUI using launcher option [1] and connect to it with
- your browser by accessing `http://localhost:9090`. If the browser
- and server are running on different machines on your LAN, add the
- option `--host 0.0.0.0` to the `invoke.sh` launch command line and connect to
- the machine hosting the web server using its IP address or domain
- name.
-
-2. If all goes well, the WebUI should come up and you'll see a green dot
- meaning `connected` on the upper right.
-
-{ align=right width=300px }
-
-#### Basics
-
-1. Generate an image by typing _bluebird_ into the large prompt field
- on the upper left and then clicking on the Invoke button or pressing
- the return button.
- After a short wait, you'll see a large image of a bluebird in the
- image panel, and a new thumbnail in the gallery on the right.
-
- If you need more room on the screen, you can turn the gallery off
- by typing the **g** hotkey. You can turn it back on later by clicking the
- image icon that appears in the gallery's place. The list of hotkeys can
- be found by clicking on the keyboard icon above the image gallery.
-
-2. Generate a bunch of bluebird images by increasing the number of
- requested images by adjusting the Images counter just below the Invoke
- button. As each is generated, it will be added to the gallery. You can
- switch the active image by clicking on the gallery thumbnails.
-
- If you'd like to watch the image generation progress, click the hourglass
- icon above the main image area. As generation progresses, you'll see
- increasingly detailed versions of the ultimate image.
-
-3. Try playing with different settings, including changing the main
- model, the image width and height, the Scheduler, the Steps and
- the CFG scale.
-
- The _Model_ changes the main model. Thousands of custom models are
- now available, which generate a variety of image styles and
- subjects. While InvokeAI comes with a few starter models, it is
- easy to import new models into the application. See [Installing
- Models](../installation/050_INSTALLING_MODELS.md) for more details.
-
- Image _Width_ and _Height_ do what you'd expect. However, be aware that
- larger images consume more VRAM memory and take longer to generate.
-
- The _Scheduler_ controls how the AI selects the image to display. Some
- samplers are more "creative" than others and will produce a wider range of
- variations (see next section). Some samplers run faster than others.
-
- _Steps_ controls how many noising/denoising/sampling steps the AI will take.
- The higher this value, the more refined the image will be, but the longer
- the image will take to generate. A typical strategy is to generate images
- with a low number of steps in order to select one to work on further, and
- then regenerate it using a higher number of steps.
-
- The _CFG Scale_ controls how hard the AI tries to match the generated image
- to the input prompt. You can go as high or low as you like, but generally
- values greater than 20 won't improve things much, and values lower than 5
- will produce unexpected images. There are complex interactions between
- _Steps_, _CFG Scale_ and the _Scheduler_, so experiment to find out what works
- for you.
-
- The _Seed_ controls the series of values returned by InvokeAI's
- random number generator. Each unique seed value will generate a different
- image. To regenerate a previous image, simply use the original image's
- seed value. A slider to the right of the _Seed_ field will change the
- seed each time an image is generated.
-
-{ align=right width=400px }
-
-4. To regenerate a previously-generated image, select the image you
- want and click the asterisk ("*") button at the top of the
- image. This loads the text prompt and other original settings into
- the control panel. If you then press _Invoke_ it will regenerate
- the image exactly. You can also selectively modify the prompt or
- other settings to tweak the image.
-
- Alternatively, you may click on the "sprouting plant icon" to load
- just the image's seed, and leave other settings unchanged or the
- quote icon to load just the positive and negative prompts.
-
-5. To regenerate a Stable Diffusion image that was generated by another SD
- package, you need to know its text prompt and its _Seed_. Copy-paste the
- prompt into the prompt box, unset the _Randomize Seed_ control in the
- control panel, and copy-paste the desired _Seed_ into its text field. When
- you Invoke, you will get something similar to the original image. It will
- not be exact unless you also set the correct values for the original
- sampler, CFG, steps and dimensions, but it will (usually) be close.
-
-6. To save an image, right click on it to bring up a menu that will
- let you download the image, save it to a named image gallery, and
- copy it to the clipboard, among other things.
-
-#### Upscaling
-
-{ align=right width=400px }
-
-"Upscaling" is the process of increasing the size of an image while
- retaining the sharpness. InvokeAI uses an external library called
- "ESRGAN" to do this. To invoke upscaling, simply select an image
- and press the "expanding arrows" button above it. You can select
- between 2X and 4X upscaling, and adjust the upscaling strength,
- which has much the same meaning as in facial reconstruction. Try
- running this on one of your previously-generated images.
-
-### Image to Image
-
-InvokeAI lets you take an existing image and use it as the basis for a new
-creation. You can use any sort of image, including a photograph, a scanned
-sketch, or a digital drawing, as long as it is in PNG or JPEG format.
-
-For this tutorial, we'll use the file named
-[Lincoln-and-Parrot-512.png](../assets/Lincoln-and-Parrot-512.png).
-
-1. Click on the _Image to Image_ tab icon, which is the second icon
- from the top on the left-hand side of the screen. This will bring
- you to a screen similar to the one shown here:
-
- { width="640px" }
-
-2. Drag-and-drop the Lincoln-and-Parrot image into the Image panel, or click
- the blank area to get an upload dialog. The image will load into an area
- marked _Initial Image_. (The WebUI will also load the most
- recently-generated image from the gallery into a section on the left, but
- this image will be replaced in the next step.)
-
-3. Go to the prompt box and type _old sea captain with raven on shoulder_ and
- press Invoke. A derived image will appear to the right of the original one:
-
- {:width="640px"}
-
-4. Experiment with the different settings. The most influential one in Image to
- Image is _Denoising Strength_ located about midway down the control
- panel. By default it is set to 0.75, but can range from 0.0 to 0.99. The
- higher the value, the more of the original image the AI will replace. A
- value of 0 will leave the initial image completely unchanged, while 0.99
- will replace it completely. However, the _Scheduler_ and _CFG Scale_ also
- influence the final result. You can also generate variations in the same way
- as described in Text to Image.
-
-5. What if we only want to change certain part(s) of the image and
- leave the rest intact? This is called Inpainting, and you can do
- it in the [Unified Canvas](UNIFIED_CANVAS.md). The Unified Canvas
- also allows you to extend borders of the image and fill in the
- blank areas, a process called outpainting.
-
-6. Would you like to modify a previously-generated image using the Image to
- Image facility? Easy! While in the Image to Image panel, drag and drop any
- image in the gallery into the Initial Image area, and it will be ready for
- use. You can do the same thing with the main image display. Click on the
- _Send to_ icon to get a menu of
- commands and choose "Send to Image to Image".
-
- 
-
-### Textual Inversion, LoRA and ControlNet
-
-InvokeAI supports several different types of model files that
-extending the capabilities of the main model by adding artistic
-styles, special effects, or subjects. By mixing and matching textual
-inversion, LoRA and ControlNet models, you can achieve many
-interesting and beautiful effects.
-
-We will give an example using a LoRA model named "Ink Scenery". This
-LoRA, which can be downloaded from Civitai (civitai.com), is
-specialized to paint landscapes that look like they were made with
-dripping india ink. To install this LoRA, we first download it and
-put it into the `autoimport/lora` folder located inside the
-`invokeai` root directory. After restarting the web server, the
-LoRA will now become available for use.
-
-To see this LoRA at work, we'll first generate an image without it
-using the standard `stable-diffusion-v1-5` model. Choose this
-model and enter the prompt "mountains, ink". Here is a typical
-generated image, a mountain range rendered in ink and watercolor
-wash:
-
-{ width=512px }
-
-Now let's install and activate the Ink Scenery LoRA. Go to
-https://civitai.com/models/78605/ink-scenery-or and download the LoRA
-model file to `invokeai/autoimport/lora` and restart the web
-server. (Alternatively, you can use [InvokeAI's Web Model
-Manager](../installation/050_INSTALLING_MODELS.md) to download and
-install the LoRA directly by typing its URL into the _Import
-Models_->_Location_ field).
-
-Scroll down the control panel until you get to the LoRA accordion
-section, and open it:
-
-{ width=512px }
-
-Click the popup menu and select "Ink scenery". (If it isn't there, then
-the model wasn't installed to the right place, or perhaps you forgot
-to restart the web server.) The LoRA section will change to look like this:
-
-{ width=512px }
-
-Note that there is now a slider control for _Ink scenery_. The slider
-controls how much influence the LoRA model will have on the generated
-image.
-
-Run the "mountains, ink" prompt again and observe the change in style:
-
-{ width=512px }
-
-Try adjusting the weight slider for larger and smaller weights and
-generate the image after each adjustment. The higher the weight, the
-more influence the LoRA will have.
-
-To remove the LoRA completely, just click on its trash can icon.
-
-Multiple LoRAs can be added simultaneously and combined with textual
-inversions and ControlNet models. Please see [Textual Inversions and
-LoRAs](CONCEPTS.md) and [Using ControlNet](CONTROLNET.md) for details.
-
-## Summary
-
-This walkthrough just skims the surface of the many things InvokeAI
-can do. Please see [Features](index.md) for more detailed reference
-guides.
-
-## Acknowledgements
-
-A huge shout-out to the core team working to make the Web GUI a reality,
-including [psychedelicious](https://github.com/psychedelicious),
-[Kyle0654](https://github.com/Kyle0654) and
-[blessedcoolant](https://github.com/blessedcoolant).
-[hipsterusername](https://github.com/hipsterusername) was the team's unofficial
-cheerleader and added tooltips/docs.
diff --git a/docs/features/WEBUIHOTKEYS.md b/docs/features/WEBUIHOTKEYS.md
deleted file mode 100644
index 4aa3d385fe7..00000000000
--- a/docs/features/WEBUIHOTKEYS.md
+++ /dev/null
@@ -1,75 +0,0 @@
----
-title: WebUI Hotkey List
----
-
-# :material-keyboard: **WebUI Hotkey List**
-
-## App Hotkeys
-
-| Setting | Hotkey |
-| --------------- | ------------------ |
-| ++ctrl+enter++ | Invoke |
-| ++shift+x++ | Cancel |
-| ++alt+a++ | Focus Prompt |
-| ++o++ | Toggle Options |
-| ++shift+o++ | Pin Options |
-| ++z++ | Toggle Viewer |
-| ++g++ | Toggle Gallery |
-| ++f++ | Maximize Workspace |
-| ++1++ - ++5++ | Change Tabs |
-| ++"`"++ | Toggle Console |
-
-## General Hotkeys
-
-| Setting | Hotkey |
-| -------------- | ---------------------- |
-| ++p++ | Set Prompt |
-| ++s++ | Set Seed |
-| ++a++ | Set Parameters |
-| ++shift+r++ | Restore Faces |
-| ++shift+u++ | Upscale |
-| ++i++ | Show Info |
-| ++shift+i++ | Send To Image To Image |
-| ++del++ | Delete Image |
-| ++esc++ | Close Panels |
-
-## Gallery Hotkeys
-
-| Setting | Hotkey |
-| ----------------------| --------------------------- |
-| ++arrow-left++ | Previous Image |
-| ++arrow-right++ | Next Image |
-| ++shift+g++ | Toggle Gallery Pin |
-| ++shift+arrow-up++ | Increase Gallery Image Size |
-| ++shift+arrow-down++ | Decrease Gallery Image Size |
-
-## Unified Canvas Hotkeys
-
-| Setting | Hotkey |
-| --------------------------------- | ---------------------- |
-| ++b++ | Select Brush |
-| ++e++ | Select Eraser |
-| ++bracket-left++ | Decrease Brush Size |
-| ++bracket-right++ | Increase Brush Size |
-| ++shift+bracket-left++ | Decrease Brush Opacity |
-| ++shift+bracket-right++ | Increase Brush Opacity |
-| ++v++ | Move Tool |
-| ++shift+f++ | Fill Bounding Box |
-| ++del++ / ++backspace++ | Erase Bounding Box |
-| ++c++ | Select Color Picker |
-| ++n++ | Toggle Snap |
-| ++"Hold Space"++ | Quick Toggle Move |
-| ++q++ | Toggle Layer |
-| ++shift+c++ | Clear Mask |
-| ++h++ | Hide Mask |
-| ++shift+h++ | Show/Hide Bounding Box |
-| ++shift+m++ | Merge Visible |
-| ++shift+s++ | Save To Gallery |
-| ++ctrl+c++ | Copy To Clipboard |
-| ++shift+d++ | Download Image |
-| ++ctrl+z++ | Undo |
-| ++ctrl+y++ / ++ctrl+shift+z++ | Redo |
-| ++r++ | Reset View |
-| ++arrow-left++ | Previous Staging Image |
-| ++arrow-right++ | Next Staging Image |
-| ++enter++ | Accept Staging Image |
\ No newline at end of file
diff --git a/docs/features/index.md b/docs/features/index.md
deleted file mode 100644
index 7d0c0c25e5d..00000000000
--- a/docs/features/index.md
+++ /dev/null
@@ -1,62 +0,0 @@
----
-title: Overview
----
-
-Here you can find the documentation for InvokeAI's various features.
-
-## The [Getting Started Guide](../help/gettingStartedWithAI)
-A getting started guide for those new to AI image generation.
-
-## The Basics
-### * The [Web User Interface](WEB.md)
-Guide to the Web interface. Also see the [WebUI Hotkeys Reference Guide](WEBUIHOTKEYS.md)
-
-### * The [Unified Canvas](UNIFIED_CANVAS.md)
-Build complex scenes by combine and modifying multiple images in a stepwise
-fashion. This feature combines img2img, inpainting and outpainting in
-a single convenient digital artist-optimized user interface.
-
-## Image Generation
-### * [Prompt Engineering](PROMPTS.md)
-Get the images you want with the InvokeAI prompt engineering language.
-
-### * The [LoRA, LyCORIS, LCM-LoRA Models](CONCEPTS.md)
-Add custom subjects and styles using a variety of fine-tuned models.
-
-### * [ControlNet](CONTROLNET.md)
-Learn how to install and use ControlNet models for fine control over
-image output.
-
-### * [Image-to-Image Guide](IMG2IMG.md)
-Use a seed image to build new creations.
-
-## Model Management
-
-### * [Model Installation](../installation/050_INSTALLING_MODELS.md)
-Learn how to import third-party models and switch among them. This
-guide also covers optimizing models to load quickly.
-
-### * [Merging Models](MODEL_MERGING.md)
-Teach an old model new tricks. Merge 2-3 models together to create a
-new model that combines characteristics of the originals.
-
-### * [Textual Inversion](TEXTUAL_INVERSIONS.md)
-Personalize models by adding your own style or subjects.
-
-## Other Features
-
-### * [The NSFW Checker](WATERMARK+NSFW.md)
-Prevent InvokeAI from displaying unwanted racy images.
-
-### * [Controlling Logging](LOGGING.md)
-Control how InvokeAI logs status messages.
-
-### * [Command-line Utilities](UTILITIES.md)
-A list of the command-line utilities available with InvokeAI.
-
-
diff --git a/docs/help/FAQ.md b/docs/help/FAQ.md
deleted file mode 100644
index 25880f7cd2b..00000000000
--- a/docs/help/FAQ.md
+++ /dev/null
@@ -1,244 +0,0 @@
-# FAQ
-
-!!! info "How to Reinstall"
-
- Many issues can be resolved by re-installing the application. You won't lose any data by re-installing. We suggest downloading the [latest release](https://github.com/invoke-ai/InvokeAI/releases/latest) and using it to re-install the application. Consult the [installer guide](../installation/010_INSTALL_AUTOMATED.md) for more information.
-
- When you run the installer, you'll have an option to select the version to install. If you aren't ready to upgrade, you choose the current version to fix a broken install.
-
-If the troubleshooting steps on this page don't get you up and running, please either [create an issue] or hop on [discord] for help.
-
-## How to Install
-
-You can download the latest installers [here](https://github.com/invoke-ai/InvokeAI/releases).
-
-Note that any releases marked as _pre-release_ are in a beta state. You may experience some issues, but we appreciate your help testing those! For stable/reliable installations, please install the [latest release].
-
-## Downloading models and using existing models
-
-The Model Manager tab in the UI provides a few ways to install models, including using your already-downloaded models. You'll see a popup directing you there on first startup. For more information, see the [model install docs].
-
-## Missing models after updating to v4
-
-If you find some models are missing after updating to v4, it's likely they weren't correctly registered before the update and didn't get picked up in the migration.
-
-You can use the `Scan Folder` tab in the Model Manager UI to fix this. The models will either be in the old, now-unused `autoimport` folder, or your `models` folder.
-
-- Find and copy your install's old `autoimport` folder path, install the main install folder.
-- Go to the Model Manager and click `Scan Folder`.
-- Paste the path and scan.
-- IMPORTANT: Uncheck `Inplace install`.
-- Click `Install All` to install all found models, or just install the models you want.
-
-Next, find and copy your install's `models` folder path (this could be your custom models folder path, or the `models` folder inside the main install folder).
-
-Follow the same steps to scan and import the missing models.
-
-## Slow generation
-
-- Check the [system requirements] to ensure that your system is capable of generating images.
-- Check the `ram` setting in `invokeai.yaml`. This setting tells Invoke how much of your system RAM can be used to cache models. Having this too high or too low can slow things down. That said, it's generally safest to not set this at all and instead let Invoke manage it.
-- Check the `vram` setting in `invokeai.yaml`. This setting tells Invoke how much of your GPU VRAM can be used to cache models. Counter-intuitively, if this setting is too high, Invoke will need to do a lot of shuffling of models as it juggles the VRAM cache and the currently-loaded model. The default value of 0.25 is generally works well for GPUs without 16GB or more VRAM. Even on a 24GB card, the default works well.
-- Check that your generations are happening on your GPU (if you have one). InvokeAI will log what is being used for generation upon startup. If your GPU isn't used, re-install to ensure the correct versions of torch get installed.
-- If you are on Windows, you may have exceeded your GPU's VRAM capacity and are using slower [shared GPU memory](#shared-gpu-memory-windows). There's a guide to opt out of this behaviour in the linked FAQ entry.
-
-## Shared GPU Memory (Windows)
-
-!!! tip "Nvidia GPUs with driver 536.40"
-
- This only applies to current Nvidia cards with driver 536.40 or later, released in June 2023.
-
-When the GPU doesn't have enough VRAM for a task, Windows is able to allocate some of its CPU RAM to the GPU. This is much slower than VRAM, but it does allow the system to generate when it otherwise might no have enough VRAM.
-
-When shared GPU memory is used, generation slows down dramatically - but at least it doesn't crash.
-
-If you'd like to opt out of this behavior and instead get an error when you exceed your GPU's VRAM, follow [this guide from Nvidia](https://nvidia.custhelp.com/app/answers/detail/a_id/5490).
-
-Here's how to get the python path required in the linked guide:
-
-- Run `invoke.bat`.
-- Select option 2 for developer console.
-- At least one python path will be printed. Copy the path that includes your invoke installation directory (typically the first).
-
-## Installer cannot find python (Windows)
-
-Ensure that you checked **Add python.exe to PATH** when installing Python. This can be found at the bottom of the Python Installer window. If you already have Python installed, you can re-run the python installer, choose the Modify option and check the box.
-
-## Triton error on startup
-
-This can be safely ignored. InvokeAI doesn't use Triton, but if you are on Linux and wish to dismiss the error, you can install Triton.
-
-## Updated to 3.4.0 and xformers can’t load C++/CUDA
-
-An issue occurred with your PyTorch update. Follow these steps to fix :
-
-1. Launch your invoke.bat / invoke.sh and select the option to open the developer console
-2. Run:`pip install ".[xformers]" --upgrade --force-reinstall --extra-index-url https://download.pytorch.org/whl/cu121`
- - If you run into an error with `typing_extensions`, re-open the developer console and run: `pip install -U typing-extensions`
-
-Note that v3.4.0 is an old, unsupported version. Please upgrade to the [latest release].
-
-## Install failed and says `pip` is out of date
-
-An out of date `pip` typically won't cause an installation to fail. The cause of the error can likely be found above the message that says `pip` is out of date.
-
-If you saw that warning but the install went well, don't worry about it (but you can update `pip` afterwards if you'd like).
-
-## Replicate image found online
-
-Most example images with prompts that you'll find on the internet have been generated using different software, so you can't expect to get identical results. In order to reproduce an image, you need to replicate the exact settings and processing steps, including (but not limited to) the model, the positive and negative prompts, the seed, the sampler, the exact image size, any upscaling steps, etc.
-
-## OSErrors on Windows while installing dependencies
-
-During a zip file installation or an update, installation stops with an error like this:
-
-{:width="800px"}
-
-To resolve this, re-install the application as described above.
-
-## HuggingFace install failed due to invalid access token
-
-Some HuggingFace models require you to authenticate using an [access token].
-
-Invoke doesn't manage this token for you, but it's easy to set it up:
-
-- Follow the instructions in the link above to create an access token. Copy it.
-- Run the launcher script.
-- Select option 2 (developer console).
-- Paste the following command:
-
- ```sh
- python -c "import huggingface_hub; huggingface_hub.login()"
- ```
-
-- Paste your access token when prompted and press Enter. You won't see anything when you paste it.
-- Type `n` if prompted about git credentials.
-
-If you get an error, try the command again - maybe the token didn't paste correctly.
-
-Once your token is set, start Invoke and try downloading the model again. The installer will automatically use the access token.
-
-If the install still fails, you may not have access to the model.
-
-## Stable Diffusion XL generation fails after trying to load UNet
-
-InvokeAI is working in other respects, but when trying to generate
-images with Stable Diffusion XL you get a "Server Error". The text log
-in the launch window contains this log line above several more lines of
-error messages:
-
-`INFO --> Loading model:D:\LONG\PATH\TO\MODEL, type sdxl:main:unet`
-
-This failure mode occurs when there is a network glitch during
-downloading the very large SDXL model.
-
-To address this, first go to the Model Manager and delete the
-Stable-Diffusion-XL-base-1.X model. Then, click the HuggingFace tab,
-paste the Repo ID stabilityai/stable-diffusion-xl-base-1.0 and install
-the model.
-
-## Package dependency conflicts during installation or update
-
-If you have previously installed InvokeAI or another Stable Diffusion
-package, the installer may occasionally pick up outdated libraries and
-either the installer or `invoke` will fail with complaints about
-library conflicts.
-
-To resolve this, re-install the application as described above.
-
-## Invalid configuration file
-
-Everything seems to install ok, you get a `ValidationError` when starting up the app.
-
-This is caused by an invalid setting in the `invokeai.yaml` configuration file. The error message should tell you what is wrong.
-
-Check the [configuration docs] for more detail about the settings and how to specify them.
-
-## `ModuleNotFoundError: No module named 'controlnet_aux'`
-
-`controlnet_aux` is a dependency of Invoke and appears to have been packaged or distributed strangely. Sometimes, it doesn't install correctly. This is outside our control.
-
-If you encounter this error, the solution is to remove the package from the `pip` cache and re-run the Invoke installer so a fresh, working version of `controlnet_aux` can be downloaded and installed:
-
-- Run the Invoke launcher
-- Choose the developer console option
-- Run this command: `pip cache remove controlnet_aux`
-- Close the terminal window
-- Download and run the [installer](https://github.com/invoke-ai/InvokeAI/releases/latest), selecting your current install location
-
-## Out of Memory Issues
-
-The models are large, VRAM is expensive, and you may find yourself
-faced with Out of Memory errors when generating images. Here are some
-tips to reduce the problem:
-
-!!! info "Optimizing for GPU VRAM"
-
- === "4GB VRAM GPU"
-
- This should be adequate for 512x512 pixel images using Stable Diffusion 1.5
- and derived models, provided that you do not use the NSFW checker. It won't be loaded unless you go into the UI settings and turn it on.
-
- If you are on a CUDA-enabled GPU, we will automatically use xformers or torch-sdp to reduce VRAM requirements, though you can explicitly configure this. See the [configuration docs].
-
- === "6GB VRAM GPU"
-
- This is a border case. Using the SD 1.5 series you should be able to
- generate images up to 640x640 with the NSFW checker enabled, and up to
- 1024x1024 with it disabled.
-
- If you run into persistent memory issues there are a series of
- environment variables that you can set before launching InvokeAI that
- alter how the PyTorch machine learning library manages memory. See
- for
- a list of these tweaks.
-
- === "12GB VRAM GPU"
-
- This should be sufficient to generate larger images up to about 1280x1280.
-
-## Memory Leak (Linux)
-
-If you notice a memory leak, it could be caused to memory fragmentation as models are loaded and/or moved from CPU to GPU.
-
-A workaround is to tune memory allocation with an environment variable:
-
-```bash
-# Force blocks >1MB to be allocated with `mmap` so that they are released to the system immediately when they are freed.
-MALLOC_MMAP_THRESHOLD_=1048576
-```
-
-!!! warning "Speed vs Memory Tradeoff"
-
- Your generations may be slower overall when setting this environment variable.
-
-!!! info "Possibly dependent on `libc` implementation"
-
- It's not known if this issue occurs with other `libc` implementations such as `musl`.
-
- If you encounter this issue and your system uses a different implementation, please try this environment variable and let us know if it fixes the issue.
-
-Detailed Discussion
-
-Python (and PyTorch) relies on the memory allocator from the C Standard Library (`libc`). On linux, with the GNU C Standard Library implementation (`glibc`), our memory access patterns have been observed to cause severe memory fragmentation.
-
-This fragmentation results in large amounts of memory that has been freed but can't be released back to the OS. Loading models from disk and moving them between CPU/CUDA seem to be the operations that contribute most to the fragmentation.
-
-This memory fragmentation issue can result in OOM crashes during frequent model switching, even if `ram` (the max RAM cache size) is set to a reasonable value (e.g. a OOM crash with `ram=16` on a system with 32GB of RAM).
-
-This problem may also exist on other OSes, and other `libc` implementations. But, at the time of writing, it has only been investigated on linux with `glibc`.
-
-To better understand how the `glibc` memory allocator works, see these references:
-
-- Basics:
-- Details:
-
-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]: ../installation/050_INSTALLING_MODELS.md
-[system requirements]: ../installation/INSTALL_REQUIREMENTS.md
-[latest release]: https://github.com/invoke-ai/InvokeAI/releases/latest
-[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
-[discord]: https://discord.gg/ZmtBAhwWhy
-[configuration docs]: ../features/CONFIGURATION.md
-[access token]: https://huggingface.co/docs/hub/security-tokens#how-to-manage-user-access-tokens
diff --git a/docs/help/SAMPLER_CONVERGENCE.md b/docs/help/SAMPLER_CONVERGENCE.md
deleted file mode 100644
index 61fdca7b9cc..00000000000
--- a/docs/help/SAMPLER_CONVERGENCE.md
+++ /dev/null
@@ -1,151 +0,0 @@
----
-title: Sampler Convergence
----
-
-# :material-palette-advanced: *Sampler Convergence*
-
-As features keep increasing, making the right choices for your needs can become increasingly difficult. What sampler to use? And for how many steps? Do you change the CFG value? Do you use prompt weighting? Do you allow variations?
-
-Even once you have a result, do you blend it with other images? Pass it through `img2img`? With what strength? Do you use inpainting to correct small details? Outpainting to extend cropped sections?
-
-The purpose of this series of documents is to help you better understand these tools, so you can make the best out of them. Feel free to contribute with your own findings!
-
-In this document, we will talk about sampler convergence.
-
-Looking for a short version? Here's a TL;DR in 3 tables.
-
-!!! note "Remember"
-
- - Results converge as steps (`-s`) are increased (except for `K_DPM_2_A` and `K_EULER_A`). Often at ≥ `-s100`, but may require ≥ `-s700`).
- - Producing a batch of candidate images at low (`-s8` to `-s30`) step counts can save you hours of computation.
- - `K_HEUN` and `K_DPM_2` converge in less steps (but are slower).
- - `K_DPM_2_A` and `K_EULER_A` incorporate a lot of creativity/variability.
-
-
-
-| Sampler | (3 sample avg) it/s (M1 Max 64GB, 512x512) |
-|---|---|
-| `DDIM` | 1.89 |
-| `PLMS` | 1.86 |
-| `K_EULER` | 1.86 |
-| `K_LMS` | 1.91 |
-| `K_HEUN` | 0.95 (slower) |
-| `K_DPM_2` | 0.95 (slower) |
-| `K_DPM_2_A` | 0.95 (slower) |
-| `K_EULER_A` | 1.86 |
-
-
-
-!!! tip "suggestions"
-
- For most use cases, `K_LMS`, `K_HEUN` and `K_DPM_2` are the best choices (the latter 2 run 0.5x as quick, but tend to converge 2x as quick as `K_LMS`). At very low steps (≤ `-s8`), `K_HEUN` and `K_DPM_2` are not recommended. Use `K_LMS` instead.
-
- For variability, use `K_EULER_A` (runs 2x as quick as `K_DPM_2_A`).
-
----
-
-### *Sampler results*
-
-Let's start by choosing a prompt and using it with each of our 8 samplers, running it for 10, 20, 30, 40, 50 and 100 steps.
-
-Anime. `"an anime girl" -W512 -H512 -C7.5 -S3031912972`
-
-
-
-### *Sampler convergence*
-
-Immediately, you can notice results tend to converge -that is, as `-s` (step) values increase, images look more and more similar until there comes a point where the image no longer changes.
-
-You can also notice how `DDIM` and `PLMS` eventually tend to converge to K-sampler results as steps are increased.
-Among K-samplers, `K_HEUN` and `K_DPM_2` seem to require the fewest steps to converge, and even at low step counts they are good indicators of the final result. And finally, `K_DPM_2_A` and `K_EULER_A` seem to do a bit of their own thing and don't keep much similarity with the rest of the samplers.
-
-### *Batch generation speedup*
-
-This realization is very useful because it means you don't need to create a batch of 100 images (`-n100`) at `-s100` to choose your favorite 2 or 3 images.
-You can produce the same 100 images at `-s10` to `-s30` using a K-sampler (since they converge faster), get a rough idea of the final result, choose your 2 or 3 favorite ones, and then run `-s100` on those images to polish some details.
-The latter technique is 3-8x as quick.
-
-!!! example
-
- At 60s per 100 steps.
-
- A) 60s * 100 images = 6000s (100 images at `-s100`, manually picking 3 favorites)
-
- B) 6s *100 images + 60s* 3 images = 780s (100 images at `-s10`, manually picking 3 favorites, and running those 3 at `-s100` to polish details)
-
- The result is __1 hour and 40 minutes__ for Variant A, vs __13 minutes__ for Variant B.
-
-### *Topic convergance*
-
-Now, these results seem interesting, but do they hold for other topics? How about nature? Food? People? Animals? Let's try!
-
-Nature. `"valley landscape wallpaper, d&d art, fantasy, painted, 4k, high detail, sharp focus, washed colors, elaborate excellent painted illustration" -W512 -H512 -C7.5 -S1458228930`
-
-
-
-With nature, you can see how initial results are even more indicative of final result -more so than with characters/people. `K_HEUN` and `K_DPM_2` are again the quickest indicators, almost right from the start. Results also converge faster (e.g. `K_HEUN` converged at `-s21`).
-
-Food. `"a hamburger with a bowl of french fries" -W512 -H512 -C7.5 -S4053222918`
-
-
-
-Again, `K_HEUN` and `K_DPM_2` take the fewest number of steps to be good indicators of the final result. `K_DPM_2_A` and `K_EULER_A` seem to incorporate a lot of creativity/variability, capable of producing rotten hamburgers, but also of adding lettuce to the mix. And they're the only samplers that produced an actual 'bowl of fries'!
-
-Animals. `"grown tiger, full body" -W512 -H512 -C7.5 -S3721629802`
-
-
-
-`K_HEUN` and `K_DPM_2` once again require the least number of steps to be indicative of the final result (around `-s30`), while other samplers are still struggling with several tails or malformed back legs.
-
-It also takes longer to converge (for comparison, `K_HEUN` required around 150 steps to converge). This is normal, as producing human/animal faces/bodies is one of the things the model struggles the most with. For these topics, running for more steps will often increase coherence within the composition.
-
-People. `"Ultra realistic photo, (Miranda Bloom-Kerr), young, stunning model, blue eyes, blond hair, beautiful face, intricate, highly detailed, smooth, art by artgerm and greg rutkowski and alphonse mucha, stained glass" -W512 -H512 -C7.5 -S2131956332`. This time, we will go up to 300 steps.
-
-
-
-Observing the results, it again takes longer for all samplers to converge (`K_HEUN` took around 150 steps), but we can observe good indicative results much earlier (see: `K_HEUN`). Conversely, `DDIM` and `PLMS` are still undergoing moderate changes (see: lace around her neck), even at `-s300`.
-
-In fact, as we can see in this other experiment, some samplers can take 700+ steps to converge when generating people.
-
-
-
-Note also the point of convergence may not be the most desirable state (e.g. I prefer an earlier version of the face, more rounded), but it will probably be the most coherent arms/hands/face attributes-wise. You can always merge different images with a photo editing tool and pass it through `img2img` to smoothen the composition.
-
-### *Sampler generation times*
-
-Once we understand the concept of sampler convergence, we must look into the performance of each sampler in terms of steps (iterations) per second, as not all samplers run at the same speed.
-
-
-
-On my M1 Max with 64GB of RAM, for a 512x512 image
-
-| Sampler | (3 sample average) it/s |
-| :--- | :--- |
-| `DDIM` | 1.89 |
-| `PLMS` | 1.86 |
-| `K_EULER` | 1.86 |
-| `K_LMS` | 1.91 |
-| `K_HEUN` | 0.95 (slower) |
-| `K_DPM_2` | 0.95 (slower) |
-| `K_DPM_2_A` | 0.95 (slower) |
-| `K_EULER_A` | 1.86 |
-
-
-
-Combining our results with the steps per second of each sampler, three choices come out on top: `K_LMS`, `K_HEUN` and `K_DPM_2` (where the latter two run 0.5x as quick but tend to converge 2x as quick as `K_LMS`). For creativity and a lot of variation between iterations, `K_EULER_A` can be a good choice (which runs 2x as quick as `K_DPM_2_A`).
-
-Additionally, image generation at very low steps (≤ `-s8`) is not recommended for `K_HEUN` and `K_DPM_2`. Use `K_LMS` instead.
-
-{ width=600}
-
-### *Three key points*
-
-Finally, it is relevant to mention that, in general, there are 3 important moments in the process of image formation as steps increase:
-
-* The (earliest) point at which an image becomes a good indicator of the final result (useful for batch generation at low step values, to then improve the quality/coherence of the chosen images via running the same prompt and seed for more steps).
-
-* The (earliest) point at which an image becomes coherent, even if different from the result if steps are increased (useful for batch generation at low step values, where quality/coherence is improved via techniques other than increasing the steps -e.g. via inpainting).
-
-* The point at which an image fully converges.
-
-Hence, remember that your workflow/strategy should define your optimal number of steps, even for the same prompt and seed (for example, if you seek full convergence, you may run `K_LMS` for `-s200` in the case of the red-haired girl, but `K_LMS` and `-s20`-taking one tenth the time- may do as well if your workflow includes adding small details, such as the missing shoulder strap, via `img2img`).
diff --git a/docs/help/diffusion.md b/docs/help/diffusion.md
deleted file mode 100644
index 7182a51d67f..00000000000
--- a/docs/help/diffusion.md
+++ /dev/null
@@ -1,27 +0,0 @@
-Taking the time to understand the diffusion process will help you to understand how to more effectively use InvokeAI.
-
-There are two main ways Stable Diffusion works - with images, and latents.
-
-Image space represents images in pixel form that you look at. Latent space represents compressed inputs. It’s in latent space that Stable Diffusion processes images. A VAE (Variational Auto Encoder) is responsible for compressing and encoding inputs into latent space, as well as decoding outputs back into image space.
-
-To fully understand the diffusion process, we need to understand a few more terms: UNet, CLIP, and conditioning.
-
-A U-Net is a model trained on a large number of latent images with with known amounts of random noise added. This means that the U-Net can be given a slightly noisy image and it will predict the pattern of noise needed to subtract from the image in order to recover the original.
-
-CLIP is a model that tokenizes and encodes text into conditioning. This conditioning guides the model during the denoising steps to produce a new image.
-
-The U-Net and CLIP work together during the image generation process at each denoising step, with the U-Net removing noise in such a way that the result is similar to images in the U-Net’s training set, while CLIP guides the U-Net towards creating images that are most similar to the prompt.
-
-
-When you generate an image using text-to-image, multiple steps occur in latent space:
-1. Random noise is generated at the chosen height and width. The noise’s characteristics are dictated by seed. This noise tensor is passed into latent space. We’ll call this noise A.
-2. Using a model’s U-Net, a noise predictor examines noise A, and the words tokenized by CLIP from your prompt (conditioning). It generates its own noise tensor to predict what the final image might look like in latent space. We’ll call this noise B.
-3. Noise B is subtracted from noise A in an attempt to create a latent image consistent with the prompt. This step is repeated for the number of sampler steps chosen.
-4. The VAE decodes the final latent image from latent space into image space.
-
-Image-to-image is a similar process, with only step 1 being different:
-1. The input image is encoded from image space into latent space by the VAE. Noise is then added to the input latent image. Denoising Strength dictates how many noise steps are added, and the amount of noise added at each step. A Denoising Strength of 0 means there are 0 steps and no noise added, resulting in an unchanged image, while a Denoising Strength of 1 results in the image being completely replaced with noise and a full set of denoising steps are performance. The process is then the same as steps 2-4 in the text-to-image process.
-
-Furthermore, a model provides the CLIP prompt tokenizer, the VAE, and a U-Net (where noise prediction occurs given a prompt and initial noise tensor).
-
-A noise scheduler (eg. DPM++ 2M Karras) schedules the subtraction of noise from the latent image across the sampler steps chosen (step 3 above). Less noise is usually subtracted at higher sampler steps.
diff --git a/docs/help/gettingStartedWithAI.md b/docs/help/gettingStartedWithAI.md
deleted file mode 100644
index 617bd604010..00000000000
--- a/docs/help/gettingStartedWithAI.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Getting Started with AI Image Generation
-
-New to image generation with AI? You’re in the right place!
-
-This is a high level walkthrough of some of the concepts and terms you’ll see as you start using InvokeAI. Please note, this is not an exhaustive guide and may be out of date due to the rapidly changing nature of the space.
-
-## Using InvokeAI
-
-### **Prompt Crafting**
-
-- Prompts are the basis of using InvokeAI, providing the models directions on what to generate. As a general rule of thumb, the more detailed your prompt is, the better your result will be.
-
- *To get started, here’s an easy template to use for structuring your prompts:*
-
-- Subject, Style, Quality, Aesthetic
- - **Subject:** What your image will be about. E.g. “a futuristic city with trains”, “penguins floating on icebergs”, “friends sharing beers”
- - **Style:** The style or medium in which your image will be in. E.g. “photograph”, “pencil sketch”, “oil paints”, or “pop art”, “cubism”, “abstract”
- - **Quality:** A particular aspect or trait that you would like to see emphasized in your image. E.g. "award-winning", "featured in {relevant set of high quality works}", "professionally acclaimed". Many people often use "masterpiece".
- - **Aesthetics:** The visual impact and design of the artwork. This can be colors, mood, lighting, setting, etc.
-- There are two prompt boxes: *Positive Prompt* & *Negative Prompt*.
- - A **Positive** Prompt includes words you want the model to reference when creating an image.
- - Negative Prompt is for anything you want the model to eliminate when creating an image. It doesn’t always interpret things exactly the way you would, but helps control the generation process. Always try to include a few terms - you can typically use lower quality image terms like “blurry” or “distorted” with good success.
-- Some examples prompts you can try on your own:
- - A detailed oil painting of a tranquil forest at sunset with vibrant+ colors and soft, golden light filtering through the trees
- - friends sharing beers in a busy city, realistic colored pencil sketch, twilight, masterpiece, bright, lively
-
-### Generation Workflows
-
-- Invoke offers a number of different workflows for interacting with models to produce images. Each is extremely powerful on its own, but together provide you an unparalleled way of producing high quality creative outputs that align with your vision.
- - **Text to Image:** The text to image tab focuses on the key workflow of using a prompt to generate a new image. It includes other features that help control the generation process as well.
- - **Image to Image:** With image to image, you provide an image as a reference (called the “initial image”), which provides more guidance around color and structure to the AI as it generates a new image. This is provided alongside the same features as Text to Image.
- - **Unified Canvas:** The Unified Canvas is an advanced AI-first image editing tool that is easy to use, but hard to master. Drag an image onto the canvas from your gallery in order to regenerate certain elements, edit content or colors (known as inpainting), or extend the image with an exceptional degree of consistency and clarity (called outpainting).
-
-### Improving Image Quality
-
-- 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 Prompt or Negative Prompt can help add / remove pieces of your image to improve it - You can also use advanced techniques like upweighting and downweighting to control the influence of certain words. [Learn more here](https://invoke-ai.github.io/InvokeAI/features/PROMPTS/#prompt-syntax-features).
- - **Tip: If you’re seeing poor results, try adding the things you don’t like about the image to your negative prompt may help. E.g. distorted, low quality, unrealistic, etc.**
-- Explore different models - Other models can produce different results due to the data they’ve been trained on. Each model has specific language and settings it works best with; a model’s documentation is your friend here. Play around with some and see what works best for you!
-- Increasing Steps - The number of steps used controls how much time the model is given to produce an image, and depends on the “Scheduler” used. The schedule controls how each step is processed by the model. More steps tends to mean better results, but will take longer - We recommend at least 30 steps for most
-- Tweak and Iterate - Remember, it’s best to change one thing at a time so you know what is working and what isn't. Sometimes you just need to try a new image, and other times using a new prompt might be the ticket. For testing, consider turning off the “random” Seed - Using the same seed with the same settings will produce the same image, which makes it the perfect way to learn exactly what your changes are doing.
-- 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/).
-
-
-## Terms & Concepts
-
-If you're interested in learning more, check out [this presentation](https://docs.google.com/presentation/d/1IO78i8oEXFTZ5peuHHYkVF-Y3e2M6iM5tCnc-YBfcCM/edit?usp=sharing) from one of our maintainers (@lstein).
-
-### Stable Diffusion
-
-Stable Diffusion is deep learning, text-to-image model that is the foundation of the capabilities found in InvokeAI. Since the release of Stable Diffusion, there have been many subsequent models created based on Stable Diffusion that are designed to generate specific types of images.
-
-### Prompts
-
-Prompts provide the models directions on what to generate. As a general rule of thumb, the more detailed your prompt is, the better your result will be.
-
-### Models
-
-Models are the magic that power InvokeAI. These files represent the output of training a machine on understanding massive amounts of images - providing them with the capability to generate new images using just a text description of what you’d like to see. (Like Stable Diffusion!)
-
-Invoke offers a simple way to download several different models upon installation, but many more can be discovered online, including at https://models.invoke.ai
-
-Each model can produce a unique style of output, based on the images it was trained on - Try out different models to see which best fits your creative vision!
-
-- *Models that contain “inpainting” in the name are designed for use with the inpainting feature of the Unified Canvas*
-
-### Scheduler
-
-Schedulers guide the process of removing noise (de-noising) from data. They determine:
-
-1. The number of steps to take to remove the noise.
-2. Whether the steps are random (stochastic) or predictable (deterministic).
-3. The specific method (algorithm) used for de-noising.
-
-Experimenting with different schedulers is recommended as each will produce different outputs!
-
-### Steps
-
-The number of de-noising steps each generation through.
-
-Schedulers can be intricate and there's often a balance to strike between how quickly they can de-noise data and how well they can do it. It's typically advised to experiment with different schedulers to see which one gives the best results. There has been a lot written on the internet about different schedulers, as well as exploring what the right level of "steps" are for each. You can save generation time by reducing the number of steps used, but you'll want to make sure that you are satisfied with the quality of images produced!
-
-### Low-Rank Adaptations / LoRAs
-
-Low-Rank Adaptations (LoRAs) are like a smaller, more focused version of models, intended to focus on training a better understanding of how a specific character, style, or concept looks.
-
-### Textual Inversion Embeddings
-
-Textual Inversion Embeddings, like LoRAs, assist with more easily prompting for certain characters, styles, or concepts. However, embeddings are trained to update the relationship between a specific word (known as the “trigger”) and the intended output.
-
-### ControlNet
-
-ControlNets are neural network models that are able to extract key features from an existing image and use these features to guide the output of the image generation model.
-
-### VAE
-
-Variational auto-encoder (VAE) is a encode/decode model that translates the "latents" image produced during the image generation procees to the large pixel images that we see.
-
diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico
deleted file mode 100644
index 16a72bebcbb..00000000000
Binary files a/docs/img/favicon.ico and /dev/null differ
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index d55d6a14f8f..00000000000
--- a/docs/index.md
+++ /dev/null
@@ -1,181 +0,0 @@
----
-title: Home
----
-
-
-
-
-
-
-
-
-
-
-
-
-[](https://github.com/invoke-ai/InvokeAI)
-
-[![discord badge]][discord link]
-
-[![latest release badge]][latest release link]
-[![github stars badge]][github stars link]
-[![github forks badge]][github forks link]
-
-
-
-[![github open issues badge]][github open issues link]
-[![github open prs badge]][github open prs link]
-
-[ci checks on dev badge]:
- https://flat.badgen.net/github/checks/invoke-ai/InvokeAI/development?label=CI%20status%20on%20dev&cache=900&icon=github
-[ci checks on dev link]:
- https://github.com/invoke-ai/InvokeAI/actions?query=branch%3Adevelopment
-[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/workflows/test-invoke-conda.yml
-[discord badge]: https://flat.badgen.net/discord/members/ZmtBAhwWhy?icon=discord
-[discord link]: https://discord.gg/ZmtBAhwWhy
-[github forks badge]:
- https://flat.badgen.net/github/forks/invoke-ai/InvokeAI?icon=github
-[github forks link]:
- https://useful-forks.github.io/?repo=lstein%2Fstable-diffusion
-[github open issues badge]:
- https://flat.badgen.net/github/open-issues/invoke-ai/InvokeAI?icon=github
-[github open issues link]:
- https://github.com/invoke-ai/InvokeAI/issues?q=is%3Aissue+is%3Aopen
-[github open prs badge]:
- https://flat.badgen.net/github/open-prs/invoke-ai/InvokeAI?icon=github
-[github open prs link]:
- https://github.com/invoke-ai/InvokeAI/pulls?q=is%3Apr+is%3Aopen
-[github stars badge]:
- https://flat.badgen.net/github/stars/invoke-ai/InvokeAI?icon=github
-[github stars link]: https://github.com/invoke-ai/InvokeAI/stargazers
-
-[latest release badge]:
- https://flat.badgen.net/github/release/invoke-ai/InvokeAI/development?icon=github
-[latest release link]: https://github.com/invoke-ai/InvokeAI/releases
-
-
-
-InvokeAI is an
-implementation of Stable Diffusion, the open source text-to-image and
-image-to-image generator. It provides a streamlined process with various new
-features and options to aid the image generation process. It runs on Windows,
-Mac and Linux machines, and runs on GPU cards with as little as 4 GB of RAM.
-
-
-
-## :octicons-link-24: Quick Links
-
-
-
-
-## :octicons-gift-24: InvokeAI Features
-
-### Installation
- - [Automated Installer](installation/010_INSTALL_AUTOMATED.md)
- - [Manual Installation](installation/020_INSTALL_MANUAL.md)
- - [Docker Installation](installation/040_INSTALL_DOCKER.md)
-
-### The InvokeAI Web Interface
-- [WebUI overview](features/WEB.md)
-- [WebUI hotkey reference guide](features/WEBUIHOTKEYS.md)
-- [WebUI Unified Canvas for Img2Img, inpainting and outpainting](features/UNIFIED_CANVAS.md)
-
-
-
-### Image Management
-- [Image2Image](features/IMG2IMG.md)
-- [Adding custom styles and subjects](features/CONCEPTS.md)
-- [Upscaling and Face Reconstruction](features/POSTPROCESS.md)
-- [Other Features](features/OTHER.md)
-
-
-### Model Management
-- [Installing](installation/050_INSTALLING_MODELS.md)
-- [Model Merging](features/MODEL_MERGING.md)
-- [ControlNet Models](features/CONTROLNET.md)
-- [Style/Subject Concepts and Embeddings](features/CONCEPTS.md)
-- [Watermarking and the Not Safe for Work (NSFW) Checker](features/WATERMARK+NSFW.md)
-
-### Prompt Engineering
-- [Prompt Syntax](features/PROMPTS.md)
-
-### InvokeAI Configuration
-- [Guide to InvokeAI Runtime Settings](features/CONFIGURATION.md)
-- [Database Maintenance and other Command Line Utilities](features/UTILITIES.md)
-
-## :material-target: Troubleshooting
-
-Please check out our **[:material-frequently-asked-questions:
-FAQ](help/FAQ/)** to
-get solutions for common installation problems and other issues.
-
-## :octicons-repo-push-24: Contributing
-
-Anyone who wishes to contribute to this project, whether documentation,
-features, bug fixes, code cleanup, testing, or code reviews, is very much
-encouraged to do so.
-
-[Please take a look at our Contribution documentation to learn more about contributing to InvokeAI.
-](contributing/CONTRIBUTING.md)
-
-## :octicons-person-24: Contributors
-
-This software is a combined effort of various people from across the world.
-[Check out the list of all these amazing people](other/CONTRIBUTORS.md). We
-thank them for their time, hard work and effort.
-
-## :octicons-question-24: Support
-
-For support, please use this repository's GitHub Issues tracking service. Feel
-free to send me an email if you use and like the script.
-
-Original portions of the software are Copyright (c) 2022-23
-by [The InvokeAI Team](https://github.com/invoke-ai).
-
diff --git a/docs/installation/010_INSTALL_AUTOMATED.md b/docs/installation/010_INSTALL_AUTOMATED.md
deleted file mode 100644
index 3c6c90afdcf..00000000000
--- a/docs/installation/010_INSTALL_AUTOMATED.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Automatic Install & Updates
-
-**The same packaged installer file can be used for both new installs and updates.**
-Using the installer for updates will leave everything you've added since installation, and just update the core libraries used to run Invoke.
-Simply use the same path you installed to originally.
-
-Both release and pre-release versions can be installed using the installer. It also supports install through a wheel if needed.
-
-Be sure to review the [installation requirements] and ensure your system has everything it needs to install Invoke.
-
-## Getting the Latest Installer
-
-Download the `InvokeAI-installer-vX.Y.Z.zip` file from the [latest release] page. It is at the bottom of the page, under **Assets**.
-
-After unzipping the installer, you should have a `InvokeAI-Installer` folder with some files inside, including `install.bat` and `install.sh`.
-
-## Running the Installer
-
-!!! tip
-
- Windows users should first double-click the `WinLongPathsEnabled.reg` file to prevent a failed installation due to long file paths.
-
-Double-click the install script:
-
-=== "Windows"
-
- ```sh
- install.bat
- ```
-
-=== "Linux/macOS"
-
- ```sh
- install.sh
- ```
-
-!!! info "Running the Installer from the commandline"
-
- You can also run the install script from cmd/powershell (Windows) or terminal (Linux/macOS).
-
-!!! warning "Untrusted Publisher (Windows)"
-
- You may get a popup saying the file comes from an `Untrusted Publisher`. Click `More Info` and `Run Anyway` to get past this.
-
-The installation process is simple, with a few prompts:
-
-- Select the version to install. Unless you have a specific reason to install a specific version, select the default (the latest version).
-- Select location for the install. Be sure you have enough space in this folder for the base application, as described in the [installation requirements].
-- Select a GPU device.
-
-!!! info "Slow Installation"
-
- The installer needs to download several GB of data and install it all. It may appear to get stuck at 99.9% when installing `pytorch` or during a step labeled "Installing collected packages".
-
- If it is stuck for over 10 minutes, something has probably gone wrong and you should close the window and restart.
-
-## Running the Application
-
-Find the install location you selected earlier. Double-click the launcher script to run the app:
-
-=== "Windows"
-
- ```sh
- invoke.bat
- ```
-
-=== "Linux/macOS"
-
- ```sh
- invoke.sh
- ```
-
-Choose the first option to run the UI. After a series of startup messages, you'll see something like this:
-
-```
-Uvicorn running on http://127.0.0.1:9090 (Press CTRL+C to quit)
-```
-
-Copy the URL into your browser and you should see the UI.
-
-## First-time Setup
-
-You will need to [install some models] before you can generate.
-
-Check the [configuration docs] for details on configuring the application.
-
-## Updating
-
-Updating is exactly the same as installing - download the latest installer, choose the latest version and off you go.
-
-!!! info "Dependency Resolution Issues"
-
- We've found that pip's dependency resolution can cause issues when upgrading packages. One very common problem was pip "downgrading" torch from CUDA to CPU, but things broke in other novel ways.
-
- The installer doesn't have this kind of problem, so we use it for updating as well.
-
-## Installation Issues
-
-If you have installation issues, please review the [FAQ]. You can also [create an issue] or ask for help on [discord].
-
-[installation requirements]: INSTALL_REQUIREMENTS.md
-[FAQ]: ../help/FAQ.md
-[install some models]: 050_INSTALLING_MODELS.md
-[configuration docs]: ../features/CONFIGURATION.md
-[latest release]: https://github.com/invoke-ai/InvokeAI/releases/latest
-[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
-[discord]: https://discord.gg/ZmtBAhwWhy
diff --git a/docs/installation/020_INSTALL_MANUAL.md b/docs/installation/020_INSTALL_MANUAL.md
deleted file mode 100644
index 059834eb453..00000000000
--- a/docs/installation/020_INSTALL_MANUAL.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Manual Install
-
-!!! warning "This is for Advanced Users"
-
- **Python experience is mandatory.**
-
-## Introduction
-
-InvokeAI is distributed as a python package on PyPI, installable with `pip`. There are a few things that are handled by the installer and launcher that you'll need to manage manually, described in this guide.
-
-### Requirements
-
-Before you start, go through the [installation requirements](./INSTALL_REQUIREMENTS.md).
-
-### Installation Walkthrough
-
-1. Create a directory to contain your InvokeAI library, configuration
- files, and models. This is known as the "runtime" or "root"
- directory, and often lives in your home directory under the name `invokeai`.
-
- We will refer to this directory as `INVOKEAI_ROOT`. For convenience, create an environment variable pointing to the directory.
-
- === "Linux/macOS"
-
- ```bash
- export INVOKEAI_ROOT=~/invokeai
- mkdir $INVOKEAI_ROOT
- ```
-
- === "Windows (PowerShell)"
-
- ```bash
- Set-Variable -Name INVOKEAI_ROOT -Value $Home/invokeai
- mkdir $INVOKEAI_ROOT
- ```
-
-1. Enter the root (invokeai) directory and create a virtual Python environment within it named `.venv`.
-
- !!! warning "Virtual Environment Location"
-
- While you may create the virtual environment anywhere in the file system, we recommend that you create it within the root directory as shown here. This allows the application to automatically detect its data directories.
-
- If you choose a different location for the venv, then you _must_ set the `INVOKEAI_ROOT` environment variable or specify the root directory using the `--root` CLI arg.
-
- ```terminal
- cd $INVOKEAI_ROOT
- python3 -m venv .venv --prompt InvokeAI
- ```
-
-1. Activate the new environment:
-
- === "Linux/macOS"
-
- ```bash
- source .venv/bin/activate
- ```
-
- === "Windows"
-
- ```ps
- .venv\Scripts\activate
- ```
-
- !!! info "Permissions Error (Windows)"
-
- If you get a permissions error at this point, run this command and try again
-
- `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
-
- The command-line prompt should change to to show `(InvokeAI)` at the beginning of the prompt.
-
- The following steps should be run while inside the `INVOKEAI_ROOT` directory.
-
-1. Make sure that pip is installed in your virtual environment and up to date:
-
- ```bash
- python3 -m pip install --upgrade pip
- ```
-
-1. Install the InvokeAI Package. The base command is `pip install InvokeAI --use-pep517`, but you may need to change this depending on your system and the desired features.
-
- - You may need to provide an [extra index URL](https://pip.pypa.io/en/stable/cli/pip_install/#cmdoption-extra-index-url). Select your platform configuration using [this tool on the PyTorch website](https://pytorch.org/get-started/locally/). Copy the `--extra-index-url` string from this and append it to your install command.
-
- !!! example "Install with an extra index URL"
-
- ```bash
- pip install InvokeAI --use-pep517 --extra-index-url https://download.pytorch.org/whl/cu121
- ```
-
- - If you have a CUDA GPU and want to install with `xformers`, you need to add an option to the package name. Note that `xformers` is not necessary. PyTorch includes an implementation of the SDP attention algorithm with the same performance.
-
- !!! example "Install with `xformers`"
-
- ```bash
- pip install "InvokeAI[xformers]" --use-pep517
- ```
-
-1. Deactivate and reactivate your runtime directory so that the invokeai-specific commands become available in the environment:
-
- === "Linux/macOS"
-
- ```bash
- deactivate && source .venv/bin/activate
- ```
-
- === "Windows"
-
- ```ps
- deactivate
- .venv\Scripts\activate
- ```
-
-1. Run the application:
-
- Run `invokeai-web` to start the UI. You must activate the virtual environment before running the app.
-
- !!! warning
-
- If the virtual environment is _not_ inside the root directory, then you _must_ specify the path to the root directory with `--root \path\to\invokeai` or the `INVOKEAI_ROOT` environment variable.
diff --git a/docs/installation/040_INSTALL_DOCKER.md b/docs/installation/040_INSTALL_DOCKER.md
deleted file mode 100644
index 3814b72e80e..00000000000
--- a/docs/installation/040_INSTALL_DOCKER.md
+++ /dev/null
@@ -1,112 +0,0 @@
----
-title: Installing with Docker
----
-
-# :fontawesome-brands-docker: Docker
-
-!!! warning "macOS and AMD GPU Users"
-
- We highly recommend to Install InvokeAI locally using [these instructions](INSTALLATION.md),
- because Docker containers can not access the GPU on macOS.
-
-!!! warning "AMD GPU Users"
-
- Container support for AMD GPUs has been reported to work by the community, but has not received
- extensive testing. Please make sure to set the `GPU_DRIVER=rocm` environment variable (see below), and
- use the `build.sh` script to build the image for this to take effect at build time.
-
-!!! tip "Linux and Windows Users"
-
- For optimal performance, configure your Docker daemon to access your machine's GPU.
- Docker Desktop on Windows [includes GPU support](https://www.docker.com/blog/wsl-2-gpu-support-for-docker-desktop-on-nvidia-gpus/).
- Linux users should install and configure the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
-
-## Why containers?
-
-They provide a flexible, reliable way to build and deploy InvokeAI.
-See [Processes](https://12factor.net/processes) under the Twelve-Factor App
-methodology for details on why running applications in such a stateless fashion is important.
-
-The container is configured for CUDA by default, but can be built to support AMD GPUs
-by setting the `GPU_DRIVER=rocm` environment variable at Docker image build time.
-
-Developers on Apple silicon (M1/M2/M3): You
-[can't access your GPU cores from Docker containers](https://github.com/pytorch/pytorch/issues/81224)
-and performance is reduced compared with running it directly on macOS but for
-development purposes it's fine. Once you're done with development tasks on your
-laptop you can build for the target platform and architecture and deploy to
-another environment with NVIDIA GPUs on-premises or in the cloud.
-
-## TL;DR
-
-This assumes properly configured Docker on Linux or Windows/WSL2. Read on for detailed customization options.
-
- ```bash
- # docker compose commands should be run from the `docker` directory
- cd docker
- docker compose up
- ```
-
-## Installation in a Linux container (desktop)
-
-### Prerequisites
-
-#### Install [Docker](https://github.com/santisbon/guides#docker)
-
-On the [Docker Desktop app](https://docs.docker.com/get-docker/), go to
-Preferences, Resources, Advanced. Increase the CPUs and Memory to avoid this
-[Issue](https://github.com/invoke-ai/InvokeAI/issues/342). You may need to
-increase Swap and Disk image size too.
-
-#### Get a Huggingface-Token
-
-Besides the Docker Agent you will need an Account on
-[huggingface.co](https://huggingface.co/join).
-
-After you succesfully registered your account, go to
-[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens), create
-a token and copy it, since you will need in for the next step.
-
-### Setup
-
-Set up your environmnent 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 the [CONFIGURATION](../features/CONFIGURATION.md) for further detail.
-
-At a minimum, 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.
-
-
-
-| Environment-Variable | Default value | Description |
-| ----------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `INVOKEAI_ROOT` | `~/invokeai` | **Required** - the location of your InvokeAI root directory. It will be created if it does not exist.
-| `HUGGING_FACE_HUB_TOKEN` | | InvokeAI will work without it, but some of the integrations with HuggingFace (like downloading from models from private repositories) may not work|
-| `GPU_DRIVER` | `cuda` | Optionally change this to `rocm` to build the image for AMD GPUs. NOTE: Use the `build.sh` script to build the image for this to take effect.
-
-
-
-#### Build the Image
-
-Use the standard `docker compose build` command from within the `docker` directory.
-
-If using an AMD GPU:
- a: set the `GPU_DRIVER=rocm` environment variable in `docker-compose.yml` and continue using `docker compose build` as usual, or
- b: set `GPU_DRIVER=rocm` in the `.env` file and use the `build.sh` script, provided for convenience
-
-#### Run the Container
-
-Use the standard `docker compose up` command, and generally the `docker compose` [CLI](https://docs.docker.com/compose/reference/) as usual.
-
-Once the container starts up (and configures the InvokeAI root directory if this is a new installation), you can access InvokeAI at [http://localhost:9090](http://localhost:9090)
-
-## Troubleshooting / FAQ
-
-- Q: I am running on Windows under WSL2, and am seeing a "no such file or directory" error.
-- A: Your `docker-entrypoint.sh` file likely has Windows (CRLF) as opposed to Unix (LF) line endings,
- and you may have cloned this repository before the issue was fixed. To solve this, please change
- the line endings in the `docker-entrypoint.sh` file to `LF`. You can do this in VSCode
- (`Ctrl+P` and search for "line endings"), or by using the `dos2unix` utility in WSL.
- Finally, you may delete `docker-entrypoint.sh` followed by `git pull; git checkout docker/docker-entrypoint.sh`
- to reset the file to its most recent version.
- For more information on this issue, please see the [Docker Desktop documentation](https://docs.docker.com/desktop/troubleshoot/topics/#avoid-unexpected-syntax-errors-use-unix-style-line-endings-for-files-in-containers)
diff --git a/docs/installation/050_INSTALLING_MODELS.md b/docs/installation/050_INSTALLING_MODELS.md
deleted file mode 100644
index 360a5043d39..00000000000
--- a/docs/installation/050_INSTALLING_MODELS.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Installing Models
-
-## Checkpoint and Diffusers Models
-
-The model checkpoint files (`*.ckpt`) are the Stable Diffusion "secret sauce". They are the product of training the AI on millions of captioned images gathered from multiple sources.
-
-Originally there was only a single Stable Diffusion weights file, which many people named `model.ckpt`.
-
-Today, there are thousands of models, fine tuned to excel at specific styles, genres, or themes.
-
-!!! tip "Model Formats"
-
- We also have two more popular model formats, both created [HuggingFace](https://huggingface.co/):
-
- - `safetensors`: Single file, like `.ckpt` files. Prevents malware from lurking in a model.
- - `diffusers`: Splits the model components into separate files, allowing very fast loading.
-
- InvokeAI supports all three formats. Our backend will convert models to `diffusers` format before running them. This is a transparent process.
-
-## Starter Models
-
-When you first start InvokeAI, you'll see a popup prompting you to install some starter models from the Model Manager. Click the `Starter Models` tab to see the list.
-
-You'll find a collection of popular and high-quality models available for easy download.
-
-Some models carry license terms that limit their use in commercial applications or on public servers. It's your responsibility to adhere to the license terms.
-
-## Other Models
-
-You can install other models using the Model Manager. You'll find tabs for the following install methods:
-
-- **URL or Local Path**: Provide the path to a model on your computer, or a direct link to the model. Some sites require you to use an API token to download models, which you can [set up in the config file].
-- **HuggingFace**: Paste a HF Repo ID to install it. If there are multiple models in the repo, you'll get a list to choose from. Repo IDs look like this: `XpucT/Deliberate`. There is a copy button on each repo to copy the ID.
-- **Scan Folder**: Scan a local folder for models. You can install all of the detected models in one click.
-
-!!! tip "Autoimport"
-
- The dedicated autoimport folder is removed as of v4.0.0. You can do the same thing on the **Scan Folder** tab - paste the folder you'd like to import from and then click `Install All`.
-
-### Diffusers models in HF repo subfolders
-
-HuggingFace repos can be structured in any way. Some model authors include multiple models within the same folder.
-
-In this situation, you may need to provide some additional information to identify the model you want, by adding `:subfolder_name` to the repo ID.
-
-!!! example
-
- Say you have a repo ID `monster-labs/control_v1p_sd15_qrcode_monster`, and the model you want is inside the `v2` subfolder.
-
- 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]: ../../features/CONFIGURATION#model-marketplace-api-keys
diff --git a/docs/installation/060_INSTALL_PATCHMATCH.md b/docs/installation/060_INSTALL_PATCHMATCH.md
deleted file mode 100644
index a9646f8b607..00000000000
--- a/docs/installation/060_INSTALL_PATCHMATCH.md
+++ /dev/null
@@ -1,110 +0,0 @@
----
-title: Installing PyPatchMatch
----
-
-# :material-image-size-select-large: Installing PyPatchMatch
-
-pypatchmatch is a Python module for inpainting images. It is not needed to run
-InvokeAI, but it greatly improves the quality of inpainting and outpainting and
-is recommended.
-
-Unfortunately, it is a C++ optimized module and installation can be somewhat
-challenging. This guide leads you through the steps.
-
-## Windows
-
-You're in luck! On Windows platforms PyPatchMatch will install automatically on
-Windows systems with no extra intervention.
-
-## Macintosh
-
-You need to have opencv installed so that pypatchmatch can be built:
-
-```bash
-brew install opencv
-```
-
-The next time you start `invoke`, after successfully installing opencv, pypatchmatch will be built.
-
-## Linux
-
-Prior to installing PyPatchMatch, you need to take the following steps:
-
-### Debian Based Distros
-
-1. Install the `build-essential` tools:
-
- ```sh
- sudo apt update
- sudo apt install build-essential
- ```
-
-2. Install `opencv`:
-
- ```sh
- sudo apt install python3-opencv libopencv-dev
- ```
-
-3. Activate the environment you use for invokeai, either with `conda` or with a
- virtual environment.
-
-4. Install pypatchmatch:
-
- ```sh
- pip install pypatchmatch
- ```
-
-5. Confirm that pypatchmatch is installed. At the command-line prompt enter
- `python`, and then at the `>>>` line type
- `from patchmatch import patch_match`: It should look like the following:
-
- ```py
- Python 3.10.12 (main, Jun 11 2023, 05:26:28) [GCC 11.4.0] on linux
- Type "help", "copyright", "credits" or "license" for more information.
- >>> from patchmatch import patch_match
- Compiling and loading c extensions from "/home/lstein/Projects/InvokeAI/.invokeai-env/src/pypatchmatch/patchmatch".
- rm -rf build/obj libpatchmatch.so
- mkdir: created directory 'build/obj'
- mkdir: created directory 'build/obj/csrc/'
- [dep] csrc/masked_image.cpp ...
- [dep] csrc/nnf.cpp ...
- [dep] csrc/inpaint.cpp ...
- [dep] csrc/pyinterface.cpp ...
- [CC] csrc/pyinterface.cpp ...
- [CC] csrc/inpaint.cpp ...
- [CC] csrc/nnf.cpp ...
- [CC] csrc/masked_image.cpp ...
- [link] libpatchmatch.so ...
- ```
-
-### Arch Based Distros
-
-1. Install the `base-devel` package:
-
- ```sh
- sudo pacman -Syu
- sudo pacman -S --needed base-devel
- ```
-
-2. Install `opencv` and `blas`:
-
- ```sh
- sudo pacman -S opencv blas
- ```
-
- or for CUDA support
-
- ```sh
- sudo pacman -S opencv-cuda blas
- ```
-
-3. Fix the naming of the `opencv` package configuration file:
-
- ```sh
- cd /usr/lib/pkgconfig/
- ln -sf opencv4.pc opencv.pc
- ```
-
-[**Next, Follow Steps 4-6 from the Debian Section above**](#linux)
-
-If you see no errors you're ready to go!
diff --git a/docs/installation/INSTALLATION.md b/docs/installation/INSTALLATION.md
deleted file mode 100644
index 267376f197c..00000000000
--- a/docs/installation/INSTALLATION.md
+++ /dev/null
@@ -1,48 +0,0 @@
-# Installation and Updating Overview
-
-Before installing, review the [installation requirements] to ensure your system is set up properly.
-
-See the [FAQ] for frequently-encountered installation issues.
-
-If you need more help, join our [discord] or [create an issue].
-
-Automatic Install & Updates
-
-✅ The automatic install is the best way to run InvokeAI. Check out the [installation guide] to get started.
-
-⬆️ The same installer is also the best way to update InvokeAI - Simply rerun it for the same folder you installed to.
-
-The installation process simply manages installation for the core libraries & application dependencies that run Invoke.
-Any models, images, or other assets in the Invoke root folder won't be affected by the installation process.
-
-Manual Install
-
-If you are familiar with python and want more control over the packages that are installed, you can [install InvokeAI manually via PyPI].
-
-Updates are managed by reinstalling the latest version through PyPi.
-
-Developer Install
-
-If you want to contribute to InvokeAI, consult the [developer install guide].
-
-Docker Install
-
-This method is recommended for those familiar with running Docker containers.
-
-We offer a method for creating Docker containers containing InvokeAI and its dependencies. This method is recommended for individuals with experience with Docker containers and understand the pluses and minuses of a container-based install.
-
-See the [docker installation guide].
-
-Other Installation Guides
-
-- [PyPatchMatch](060_INSTALL_PATCHMATCH.md)
-- [Installing Models](050_INSTALLING_MODELS.md)
-
-[install InvokeAI manually via PyPI]: 020_INSTALL_MANUAL.md
-[developer install guide]: INSTALL_DEVELOPMENT.md
-[docker installation guide]: 040_INSTALL_DOCKER.md
-[installation guide]: 010_INSTALL_AUTOMATED.md
-[FAQ]: ../help/FAQ.md
-[discord]: discord.gg/invoke-ai
-[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
-[installation requirements]: INSTALL_REQUIREMENTS.md
diff --git a/docs/installation/INSTALL_DEVELOPMENT.md b/docs/installation/INSTALL_DEVELOPMENT.md
deleted file mode 100644
index ead6b3bc8d6..00000000000
--- a/docs/installation/INSTALL_DEVELOPMENT.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Developer Install
-
-!!! warning
-
- InvokeAI uses a SQLite database. By running on `main`, you accept responsibility for your database. This
- means making regular backups (especially before pulling) and/or fixing it yourself in the event that a
- PR introduces a schema change.
-
- If you don't need persistent backend storage, you can use an ephemeral in-memory database by setting
- `use_memory_db: true` in your `invokeai.yaml` file. You'll also want to set `scan_models_on_startup: true`
- so that your models are registered on startup.
-
- If this is untenable, you should run the application via the official installer or a manual install of the
- python package from PyPI. These releases will not break your database.
-
-If you have an interest in how InvokeAI works, or you would like to add features or bugfixes, you are encouraged to install the source code for InvokeAI.
-
-!!! info "Why do I need the frontend toolchain?"
-
- The repo doesn't contain a build of the frontend. You'll be responsible for rebuilding it (or running it in dev mode) to use the app, as described in the [frontend dev toolchain] docs.
-
- Installation
-
-1. [Fork and clone] the [InvokeAI repo].
-1. Follow the [manual installation] docs to create a new virtual environment for the development install.
- - Create a new folder outside the repo root for the installation and create the venv inside that folder.
- - When installing the InvokeAI package, add `-e` to the command so you get an [editable install].
-1. Install the [frontend dev toolchain] and do a production build of the UI as described.
-1. You can now run the app as described in the [manual installation] docs.
-
-As described in the [frontend dev toolchain] docs, you can run the UI using a dev server. If you do this, you won't need to continually rebuild the frontend. Instead, you run the dev server and use the app with the server URL it provides.
-
-[Fork and clone]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo
-[InvokeAI repo]: https://github.com/invoke-ai/InvokeAI
-[frontend dev toolchain]: ../contributing/frontend/OVERVIEW.md
-[manual installation]: ./020_INSTALL_MANUAL.md
-[editable install]: https://pip.pypa.io/en/latest/cli/pip_install/#cmdoption-e
diff --git a/docs/installation/INSTALL_REQUIREMENTS.md b/docs/installation/INSTALL_REQUIREMENTS.md
deleted file mode 100644
index 2279e7efb8a..00000000000
--- a/docs/installation/INSTALL_REQUIREMENTS.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# Requirements
-
-## GPU
-
-!!! warning "Problematic Nvidia GPUs"
-
- We do not recommend these GPUs. They cannot operate with half precision, but have insufficient VRAM to generate 512x512 images at full precision.
-
- - NVIDIA 10xx series cards such as the 1080 TI
- - GTX 1650 series cards
- - GTX 1660 series cards
-
-Invoke runs best with a dedicated GPU, but will fall back to running on CPU, albeit much slower. You'll need a beefier GPU for SDXL.
-
-!!! example "Stable Diffusion 1.5"
-
- === "Nvidia"
-
- ```
- Any GPU with at least 4GB VRAM.
- ```
-
- === "AMD"
-
- ```
- Any GPU with at least 4GB VRAM. Linux only.
- ```
-
- === "Mac"
-
- ```
- Any Apple Silicon Mac with at least 8GB memory.
- ```
-
-!!! example "Stable Diffusion XL"
-
- === "Nvidia"
-
- ```
- Any GPU with at least 8GB VRAM.
- ```
-
- === "AMD"
-
- ```
- Any GPU with at least 16GB VRAM. Linux only.
- ```
-
- === "Mac"
-
- ```
- Any Apple Silicon Mac with at least 16GB memory.
- ```
-
-## RAM
-
-At least 12GB of RAM.
-
-## Disk
-
-SSDs will, of course, offer the best performance.
-
-The base application disk usage depends on the torch backend.
-
-!!! example "Disk"
-
- === "Nvidia (CUDA)"
-
- ```
- ~6.5GB
- ```
-
- === "AMD (ROCm)"
-
- ```
- ~12GB
- ```
-
- === "Mac (MPS)"
-
- ```
- ~3.5GB
- ```
-
-You'll need to set aside some space for images, depending on how much you generate. A couple GB is enough to get started.
-
-You'll need a good chunk of space for models. Even if you only install the most popular models and the usual support models (ControlNet, IP Adapter ,etc), you will quickly hit 50GB of models.
-
-!!! info "`tmpfs` on Linux"
-
- If your temporary directory is mounted as a `tmpfs`, ensure it has sufficient space.
-
-## Python
-
-Invoke requires python 3.10 or 3.11. If you don't already have one of these versions installed, we suggest installing 3.11, as it will be supported for longer.
-
-Check that your system has an up-to-date Python installed by running `python --version` in the terminal (Linux, macOS) or cmd/powershell (Windows).
-
-Installing Python (Windows)
-
-- Install python 3.11 with [an official installer].
-- The installer includes an option to add python to your PATH. Be sure to enable this. If you missed it, re-run the installer, choose to modify an existing installation, and tick that checkbox.
-- You may need to install [Microsoft Visual C++ Redistributable].
-
-Installing Python (macOS)
-
-- Install python 3.11 with [an official installer].
-- If model installs fail with a certificate error, you may need to run this command (changing the python version to match what you have installed): `/Applications/Python\ 3.10/Install\ Certificates.command`
-- If you haven't already, you will need to install the XCode CLI Tools by running `xcode-select --install` in a terminal.
-
-Installing Python (Linux)
-
-- Follow the [linux install instructions], being sure to install python 3.11.
-- You'll need to install `libglib2.0-0` and `libgl1-mesa-glx` for OpenCV to work. For example, on a Debian system: `sudo apt update && sudo apt install -y libglib2.0-0 libgl1-mesa-glx`
-
-## Drivers
-
-If you have an Nvidia or AMD GPU, you may need to manually install drivers or other support packages for things to work well or at all.
-
-### Nvidia
-
-Run `nvidia-smi` on your system's command line to verify that drivers and CUDA are installed. If this command fails, or doesn't report versions, you will need to install drivers.
-
-Go to the [CUDA Toolkit Downloads] and carefully follow the instructions for your system to get everything installed.
-
-Confirm that `nvidia-smi` displays driver and CUDA versions after installation.
-
-#### Linux - via Nvidia Container Runtime
-
-An alternative to installing CUDA locally is to use the [Nvidia Container Runtime] to run the application in a container.
-
-#### Windows - Nvidia cuDNN DLLs
-
-An out-of-date cuDNN library can greatly hamper performance on 30-series and 40-series cards. Check with the community on discord to compare your `it/s` if you think you may need this fix.
-
-First, locate the destination for the DLL files and make a quick back up:
-
-1. Find your InvokeAI installation folder, e.g. `C:\Users\Username\InvokeAI\`.
-1. Open the `.venv` folder, e.g. `C:\Users\Username\InvokeAI\.venv` (you may need to show hidden files to see it).
-1. Navigate deeper to the `torch` package, e.g. `C:\Users\Username\InvokeAI\.venv\Lib\site-packages\torch`.
-1. Copy the `lib` folder inside `torch` and back it up somewhere.
-
-Next, download and copy the updated cuDNN DLLs:
-
-1. Go to .
-1. Create an account if needed and log in.
-1. Choose the newest version of cuDNN that works with your GPU architecture. Consult the [cuDNN support matrix] to determine the correct version for your GPU.
-1. Download the latest version and extract it.
-1. Find the `bin` folder, e.g. `cudnn-windows-x86_64-SOME_VERSION\bin`.
-1. Copy and paste the `.dll` files into the `lib` folder you located earlier. Replace files when prompted.
-
-If, after restarting the app, this doesn't improve your performance, either restore your back up or re-run the installer to reset `torch` back to its original state.
-
-### AMD
-
-!!! info "Linux Only"
-
- AMD GPUs are supported on Linux only, due to ROCm (the AMD equivalent of CUDA) support being Linux only.
-
-!!! warning "Bumps Ahead"
-
- While the application does run on AMD GPUs, there are occasional bumps related to spotty torch support.
-
-Run `rocm-smi` on your system's command line verify that drivers and ROCm are installed. If this command fails, or doesn't report versions, you will need to install them.
-
-Go to the [ROCm Documentation] and carefully follow the instructions for your system to get everything installed.
-
-Confirm that `rocm-smi` displays driver and CUDA versions after installation.
-
-#### Linux - via Docker Container
-
-An alternative to installing ROCm locally is to use a [ROCm docker container] to run the application in a container.
-
-[ROCm docker container]: https://github.com/ROCm/ROCm-docker
-[ROCm Documentation]: https://rocm.docs.amd.com/projects/install-on-linux/en/latest/tutorial/quick-start.html
-[cuDNN support matrix]: https://docs.nvidia.com/deeplearning/cudnn/support-matrix/index.html
-[Nvidia Container Runtime]: https://developer.nvidia.com/container-runtime
-[linux install instructions]: https://docs.python-guide.org/starting/install3/linux/
-[Microsoft Visual C++ Redistributable]: https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist?view=msvc-170
-[an official installer]: https://www.python.org/downloads/release/python-3118/
-[CUDA Toolkit Downloads]: https://developer.nvidia.com/cuda-downloads
diff --git a/installer/lib/__init__.py b/docs/invoke-config.json
similarity index 100%
rename from installer/lib/__init__.py
rename to docs/invoke-config.json
diff --git a/docs/javascripts/init_kapa_widget.js b/docs/javascripts/init_kapa_widget.js
deleted file mode 100644
index 06885c464cd..00000000000
--- a/docs/javascripts/init_kapa_widget.js
+++ /dev/null
@@ -1,10 +0,0 @@
-document.addEventListener("DOMContentLoaded", function () {
- var script = document.createElement("script");
- script.src = "https://widget.kapa.ai/kapa-widget.bundle.js";
- script.setAttribute("data-website-id", "b5973bb1-476b-451e-8cf4-98de86745a10");
- script.setAttribute("data-project-name", "Invoke.AI");
- script.setAttribute("data-project-color", "#11213C");
- script.setAttribute("data-project-logo", "https://avatars.githubusercontent.com/u/113954515?s=280&v=4");
- script.async = true;
- document.head.appendChild(script);
-});
diff --git a/docs/javascripts/tablesort.js b/docs/javascripts/tablesort.js
deleted file mode 100644
index ceb1f94acdb..00000000000
--- a/docs/javascripts/tablesort.js
+++ /dev/null
@@ -1,7 +0,0 @@
-document$.subscribe(function() {
- var tables = document.querySelectorAll("article table:not([class])")
- tables.forEach(function(table) {
- new Tablesort(table)
- })
- })
-
\ No newline at end of file
diff --git a/docs/nodes/INVOCATION_API.md b/docs/nodes/INVOCATION_API.md
deleted file mode 100644
index 40dfca8d7db..00000000000
--- a/docs/nodes/INVOCATION_API.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# Invocation API
-
-Each invocation's `invoke` method is provided a single arg - the Invocation
-Context.
-
-This object provides access to various methods, used to interact with the
-application. Loading and saving images, logging messages, etc.
-
-!!! warning ""
-
- This API may shift slightly until the release of v4.0.0 as we work through a few final updates to the Model Manager.
-
-```py
-class MyInvocation(BaseInvocation):
- ...
- def invoke(self, context: InvocationContext) -> ImageOutput:
- image_pil = context.images.get_pil(image_name)
- # Do something to the image
- image_dto = context.images.save(image_pil)
- # Log a message
- context.logger.info(f"Did something cool, image saved!")
- ...
-```
-
-The full API is documented below.
-
-## Invocation Mixins
-
-Two important mixins are provided to facilitate working with metadata and gallery boards.
-
-### `WithMetadata`
-
-Inherit from this class (in addition to `BaseInvocation`) to add a `metadata` input to your node. When you do this, you can access the metadata dict from `self.metadata` in the `invoke()` function.
-
-The dict will be populated via the node's input, and you can add any metadata you'd like to it. When you call `context.images.save()`, if the metadata dict has any data, it be automatically embedded in the image.
-
-### `WithBoard`
-
-Inherit from this class (in addition to `BaseInvocation`) to add a `board` input to your node. This renders as a drop-down to select a board. The user's selection will be accessible from `self.board` in the `invoke()` function.
-
-When you call `context.images.save()`, if a board was selected, the image will added to that board as it is saved.
-
-
-::: invokeai.app.services.shared.invocation_context.InvocationContext
- options:
- members: false
-
-::: invokeai.app.services.shared.invocation_context.ImagesInterface
-
-::: invokeai.app.services.shared.invocation_context.TensorsInterface
-
-::: invokeai.app.services.shared.invocation_context.ConditioningInterface
-
-::: invokeai.app.services.shared.invocation_context.ModelsInterface
-
-::: invokeai.app.services.shared.invocation_context.LoggerInterface
-
-::: invokeai.app.services.shared.invocation_context.ConfigInterface
-
-::: invokeai.app.services.shared.invocation_context.UtilInterface
-
-::: invokeai.app.services.shared.invocation_context.BoardsInterface
-
diff --git a/docs/nodes/NODES.md b/docs/nodes/NODES.md
deleted file mode 100644
index b7f7aa82ad4..00000000000
--- a/docs/nodes/NODES.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Using the Workflow Editor
-
-The workflow editor is a blank canvas allowing for the use of individual functions and image transformations to control the image generation workflow. Nodes take in inputs on the left side of the node, and return an output on the right side of the node. 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.
-
-If you're not familiar with Diffusion, take a look at our [Diffusion Overview.](../help/diffusion.md) Understanding how diffusion works will enable you to more easily use the Workflow Editor and build workflows to suit your needs.
-
-## Features
-
-### Workflow Library
-The Workflow Library enables you to save workflows to the Invoke database, allowing you to easily creating, modify and share workflows as needed.
-
-A curated set of workflows are provided by default - these are designed to help explain important nodes' usage in the Workflow Editor.
-
-
-
-### Linear View
-The Workflow Editor allows you to create a UI for your workflow, to make it easier to iterate on your generations.
-
-To add an input to the Linear UI, right click on the **input label** and select "Add to Linear View".
-
-The Linear UI View will also be part of the saved workflow, allowing you share workflows and enable other to use them, regardless of complexity.
-
-
-
-### Renaming Fields and Nodes
-Any node or input field can be renamed in the workflow editor. If the input field you have renamed has been added to the Linear View, the changed name will be reflected in the Linear View and the node.
-
-### Managing Nodes
-
-* Ctrl+C to copy a node
-* Ctrl+V to paste a node
-* Backspace/Delete to delete a node
-* Shift+Click to drag and select multiple nodes
-
-### Node Caching
-
-Nodes have a "Use Cache" option in their footer. This allows for performance improvements by using the previously cached values during the workflow processing.
-
-
-## Important Nodes & Concepts
-
-There are several node grouping concepts that can be examined with a narrow focus. These (and other) groupings can be pieced together to make up functional graph setups, and are important to understanding how groups of nodes work together as part of a whole. Note that the screenshots below aren't examples of complete functioning node graphs (see Examples).
-
-### Noise
-
-An initial noise tensor is necessary for the latent diffusion process. As a result, the Denoising node requires a noise node input.
-
-
-
-### Text Prompt Conditioning
-
-Conditioning is necessary for the latent diffusion process, whether empty or not. As a result, the Denoising node requires positive and negative conditioning inputs. Conditioning is reliant on a CLIP text encoder provided by the Model Loader node.
-
-
-
-### Image to Latents & VAE
-
-The ImageToLatents node takes in a pixel image and a VAE and outputs a latents. The LatentsToImage node does the opposite, taking in a latents and a VAE and outpus a pixel image.
-
-
-
-### Defined & Random Seeds
-
-It is common to want to use both the same seed (for continuity) and random seeds (for variety). To define a seed, simply enter it into the 'Seed' field on a noise node. Conversely, the RandomInt node generates a random integer between 'Low' and 'High', and can be used as input to the 'Seed' edge point on a noise node to randomize your seed.
-
-
-
-### ControlNet
-
-The ControlNet node outputs a Control, which can be provided as input to a Denoise Latents node. Depending on the type of ControlNet desired, ControlNet nodes usually require an image processor node, such as a Canny Processor or Depth Processor, which prepares an input image for use with ControlNet.
-
-
-
-### LoRA
-
-The Lora Loader node lets you load a LoRA and pass it as output.A LoRA provides fine-tunes to the UNet and text encoder weights that augment the base model’s image and text vocabularies.
-
-
-
-### Scaling
-
-Use the ImageScale, ScaleLatents, and Upscale nodes to upscale images and/or latent images. Upscaling is the process of enlarging an image and adding more detail. The chosen method differs across contexts. However, be aware that latents are already noisy and compressed at their original resolution; scaling an image could produce more detailed results.
-
-
-
-### Iteration + Multiple Images as Input
-
-Iteration is a common concept in any processing, and means to repeat a process with given input. In nodes, you're able to use the Iterate node to iterate through collections usually gathered by the Collect node. The Iterate node has many potential uses, from processing a collection of images one after another, to varying seeds across multiple image generations and more. This screenshot demonstrates how to collect several images and use them in an image generation workflow.
-
-
-
-### Batch / Multiple Image Generation + Random Seeds
-
-Batch or multiple image generation in the workflow editor is done using the RandomRange node. In this case, the 'Size' field represents the number of images to generate, meaning this example will generate 4 images. As RandomRange produces a collection of integers, we need to add the Iterate node to iterate through the collection. This noise can then be fed to the Denoise Latents node for it to iterate through the denoising process with the different seeds provided.
-
-
-
diff --git a/docs/nodes/NODES_MIGRATION_V3_V4.md b/docs/nodes/NODES_MIGRATION_V3_V4.md
deleted file mode 100644
index 3ba08545817..00000000000
--- a/docs/nodes/NODES_MIGRATION_V3_V4.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# Invoke v4.0.0 Nodes API Migration guide
-
-Invoke v4.0.0 is versioned as such due to breaking changes to the API utilized
-by nodes, both core and custom.
-
-## Motivation
-
-Prior to v4.0.0, the `invokeai` python package has not be set up to be utilized
-as a library. That is to say, it didn't have any explicitly public API, and node
-authors had to work with the unstable internal application API.
-
-v4.0.0 introduces a stable public API for nodes.
-
-## Changes
-
-There are two node-author-facing changes:
-
-1. Import Paths
-1. Invocation Context API
-
-### Import Paths
-
-All public objects are now exported from `invokeai.invocation_api`:
-
-```py
-# Old
-from invokeai.app.invocations.baseinvocation import (
- BaseInvocation,
- InputField,
- InvocationContext,
- invocation,
-)
-from invokeai.app.invocations.primitives import ImageField
-
-# New
-from invokeai.invocation_api import (
- BaseInvocation,
- ImageField,
- InputField,
- InvocationContext,
- invocation,
-)
-```
-
-It's possible that we've missed some classes you need in your node. Please let
-us know if that's the case.
-
-### Invocation Context API
-
-Most nodes utilize the Invocation Context, an object that is passed to the
-`invoke` that provides access to data and services a node may need.
-
-Until now, that object and the services it exposed were internal. Exposing them
-to nodes means that changes to our internal implementation could break nodes.
-The methods on the services are also often fairly complicated and allowed nodes
-to footgun.
-
-In v4.0.0, this object has been refactored to be much simpler.
-
-See [INVOCATION_API](./INVOCATION_API.md) for full details of the API.
-
-!!! warning ""
-
- This API may shift slightly until the release of v4.0.0 as we work through a few final updates to the Model Manager.
-
-#### Improved Service Methods
-
-The biggest offender was the image save method:
-
-```py
-# Old
-image_dto = context.services.images.create(
- image=image,
- image_origin=ResourceOrigin.INTERNAL,
- image_category=ImageCategory.GENERAL,
- node_id=self.id,
- session_id=context.graph_execution_state_id,
- is_intermediate=self.is_intermediate,
- metadata=self.metadata,
- workflow=context.workflow,
-)
-
-# New
-image_dto = context.images.save(image=image)
-```
-
-Other methods are simplified, or enhanced with additional functionality:
-
-```py
-# Old
-image = context.services.images.get_pil_image(image_name)
-
-# New
-image = context.images.get_pil(image_name)
-image_cmyk = context.images.get_pil(image_name, "CMYK")
-```
-
-We also had some typing issues around tensors:
-
-```py
-# Old
-# `latents` typed as `torch.Tensor`, but could be `ConditioningFieldData`
-latents = context.services.latents.get(self.latents.latents_name)
-# `data` typed as `torch.Tenssor,` but could be `ConditioningFieldData`
-context.services.latents.save(latents_name, data)
-
-# New - separate methods for tensors and conditioning data w/ correct typing
-# Also, the service generates the names
-tensor_name = context.tensors.save(tensor)
-tensor = context.tensors.load(tensor_name)
-# For conditioning
-cond_name = context.conditioning.save(cond_data)
-cond_data = context.conditioning.load(cond_name)
-```
-
-#### Output Construction
-
-Core Outputs have builder functions right on them - no need to manually
-construct these objects, or use an extra utility:
-
-```py
-# Old
-image_output = ImageOutput(
- image=ImageField(image_name=image_dto.image_name),
- width=image_dto.width,
- height=image_dto.height,
-)
-latents_output = build_latents_output(latents_name=name, latents=latents, seed=None)
-noise_output = NoiseOutput(
- noise=LatentsField(latents_name=latents_name, seed=seed),
- width=latents.size()[3] * 8,
- height=latents.size()[2] * 8,
-)
-cond_output = ConditioningOutput(
- conditioning=ConditioningField(
- conditioning_name=conditioning_name,
- ),
-)
-
-# New
-image_output = ImageOutput.build(image_dto)
-latents_output = LatentsOutput.build(latents_name=name, latents=noise, seed=self.seed)
-noise_output = NoiseOutput.build(latents_name=name, latents=noise, seed=self.seed)
-cond_output = ConditioningOutput.build(conditioning_name)
-```
-
-You can still create the objects using constructors if you want, but we suggest
-using the builder methods.
diff --git a/docs/nodes/comfyToInvoke.md b/docs/nodes/comfyToInvoke.md
deleted file mode 100644
index 2d894dc74c9..00000000000
--- a/docs/nodes/comfyToInvoke.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# ComfyUI to InvokeAI
-
-If you're coming to InvokeAI from ComfyUI, welcome! You'll find things are similar but different - the good news is that you already know how things should work, and it's just a matter of wiring them up!
-
-Some things to note:
-
-- 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 have over your workflows.
-- InvokeAI's backend and ComfyUI's backend are very different which means Comfy workflows are not able to be imported into InvokeAI. However, we have created a [list of popular workflows](exampleWorkflows.md) for you to get started with Nodes in InvokeAI!
-
-## Node Equivalents:
-
-| Comfy UI Category | ComfyUI Node | Invoke Equivalent |
-|:---------------------------------- |:---------------------------------- | :----------------------------------|
-| Sampling |KSampler |Denoise Latents|
-| Sampling |Ksampler Advanced|Denoise Latents |
-| Loaders |Load Checkpoint | Main Model Loader _or_ SDXL Main Model Loader|
-| Loaders |Load VAE | VAE Loader |
-| Loaders |Load Lora | LoRA Loader _or_ SDXL Lora Loader|
-| Loaders |Load ControlNet Model | ControlNet|
-| Loaders |Load ControlNet Model (diff) | ControlNet|
-| Loaders |Load Style Model | Reference Only ControlNet will be coming in a future version of InvokeAI|
-| Loaders |unCLIPCheckpointLoader | N/A |
-| Loaders |GLIGENLoader | N/A |
-| Loaders |Hypernetwork Loader | N/A |
-| Loaders |Load Upscale Model | Occurs within "Upscale (RealESRGAN)"|
-|Conditioning |CLIP Text Encode (Prompt) | Compel (Prompt) or SDXL Compel (Prompt) |
-|Conditioning |CLIP Set Last Layer | CLIP Skip|
-|Conditioning |Conditioning (Average) | Use the .blend() feature of prompts |
-|Conditioning |Conditioning (Combine) | N/A |
-|Conditioning |Conditioning (Concat) | See the Prompt Tools Community Node|
-|Conditioning |Conditioning (Set Area) | N/A |
-|Conditioning |Conditioning (Set Mask) | Mask Edge |
-|Conditioning |CLIP Vision Encode | N/A |
-|Conditioning |unCLIPConditioning | N/A |
-|Conditioning |Apply ControlNet | ControlNet |
-|Conditioning |Apply ControlNet (Advanced) | ControlNet |
-|Latent |VAE Decode | Latents to Image|
-|Latent |VAE Encode | Image to Latents |
-|Latent |Empty Latent Image | Noise |
-|Latent |Upscale Latent |Resize Latents |
-|Latent |Upscale Latent By |Scale Latents |
-|Latent |Latent Composite | Blend Latents |
-|Latent |LatentCompositeMasked | N/A |
-|Image |Save Image | Image |
-|Image |Preview Image |Current |
-|Image |Load Image | Image|
-|Image |Empty Image| Blank Image |
-|Image |Invert Image | Invert Lerp Image |
-|Image |Batch Images | Link "Image" nodes into an "Image Collection" node |
-|Image |Pad Image for Outpainting | Outpainting is easily accomplished in the Unified Canvas |
-|Image |ImageCompositeMasked | Paste Image |
-|Image | Upscale Image | Resize Image |
-|Image | Upscale Image By | Upscale Image |
-|Image | Upscale Image (using Model) | Upscale Image |
-|Image | ImageBlur | Blur Image |
-|Image | ImageQuantize | N/A |
-|Image | ImageSharpen | N/A |
-|Image | Canny | Canny Processor |
-|Mask |Load Image (as Mask) | Image |
-|Mask |Convert Mask to Image | Image|
-|Mask |Convert Image to Mask | Image |
-|Mask |SolidMask | N/A |
-|Mask |InvertMask |Invert Lerp Image |
-|Mask |CropMask | Crop Image |
-|Mask |MaskComposite | Combine Mask |
-|Mask |FeatherMask | Blur Image |
-|Advanced | Load CLIP | Main Model Loader _or_ SDXL Main Model Loader|
-|Advanced | UNETLoader | Main Model Loader _or_ SDXL Main Model Loader|
-|Advanced | DualCLIPLoader | Main Model Loader _or_ SDXL Main Model Loader|
-|Advanced | Load Checkpoint | Main Model Loader _or_ SDXL Main Model Loader |
-|Advanced | ConditioningZeroOut | N/A |
-|Advanced | ConditioningSetTimestepRange | N/A |
-|Advanced | CLIPTextEncodeSDXLRefiner | Compel (Prompt) or SDXL Compel (Prompt) |
-|Advanced | CLIPTextEncodeSDXL |Compel (Prompt) or SDXL Compel (Prompt) |
-|Advanced | ModelMergeSimple | Model Merging is available in the Model Manager |
-|Advanced | ModelMergeBlocks | Model Merging is available in the Model Manager|
-|Advanced | CheckpointSave | Model saving is available in the Model Manager|
-|Advanced | CLIPMergeSimple | N/A |
-
-
diff --git a/docs/nodes/communityNodes.md b/docs/nodes/communityNodes.md
deleted file mode 100644
index 296fbb7ee61..00000000000
--- a/docs/nodes/communityNodes.md
+++ /dev/null
@@ -1,605 +0,0 @@
-# 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).
-
-If you'd like to submit a node for the community, please refer to the [node creation overview](contributingNodes.md).
-
-To use a node, add the node to the `nodes` folder found in your InvokeAI install location.
-
-The suggested method is to use `git clone` to clone the repository the node is found in. This allows for easy updates of the node in the future.
-
-If you'd prefer, you can also just download the whole node folder from the linked repository and add it to the `nodes` folder.
-
-To use a community workflow, download the the `.json` node graph file and load it into Invoke AI via the **Load Workflow** button in the Workflow Editor.
-
-- Community Nodes
- + [Adapters-Linked](#adapters-linked-nodes)
- + [Autostereogram](#autostereogram-nodes)
- + [Average Images](#average-images)
- + [Clean Image Artifacts After Cut](#clean-image-artifacts-after-cut)
- + [Close Color Mask](#close-color-mask)
- + [Clothing Mask](#clothing-mask)
- + [Contrast Limited Adaptive Histogram Equalization](#contrast-limited-adaptive-histogram-equalization)
- + [Depth Map from Wavefront OBJ](#depth-map-from-wavefront-obj)
- + [Film Grain](#film-grain)
- + [Generative Grammar-Based Prompt Nodes](#generative-grammar-based-prompt-nodes)
- + [GPT2RandomPromptMaker](#gpt2randompromptmaker)
- + [Grid to Gif](#grid-to-gif)
- + [Halftone](#halftone)
- + [Hand Refiner with MeshGraphormer](#hand-refiner-with-meshgraphormer)
- + [Image and Mask Composition Pack](#image-and-mask-composition-pack)
- + [Image Dominant Color](#image-dominant-color)
- + [Image to Character Art Image Nodes](#image-to-character-art-image-nodes)
- + [Image Picker](#image-picker)
- + [Image Resize Plus](#image-resize-plus)
- + [Latent Upscale](#latent-upscale)
- + [Load Video Frame](#load-video-frame)
- + [Make 3D](#make-3d)
- + [Mask Operations](#mask-operations)
- + [Match Histogram](#match-histogram)
- + [Metadata-Linked](#metadata-linked-nodes)
- + [Negative Image](#negative-image)
- + [Nightmare Promptgen](#nightmare-promptgen)
- + [Oobabooga](#oobabooga)
- + [Prompt Tools](#prompt-tools)
- + [Remote Image](#remote-image)
- + [BriaAI Background Remove](#briaai-remove-background)
- + [Remove Background](#remove-background)
- + [Retroize](#retroize)
- + [Size Stepper Nodes](#size-stepper-nodes)
- + [Simple Skin Detection](#simple-skin-detection)
- + [Text font to Image](#text-font-to-image)
- + [Thresholding](#thresholding)
- + [Unsharp Mask](#unsharp-mask)
- + [XY Image to Grid and Images to Grids nodes](#xy-image-to-grid-and-images-to-grids-nodes)
-- [Example Node Template](#example-node-template)
-- [Disclaimer](#disclaimer)
-- [Help](#help)
-
-
---------------------------------
-### Adapters Linked Nodes
-
-**Description:** A set of nodes for linked adapters (ControlNet, IP-Adaptor & T2I-Adapter). This allows multiple adapters to be chained together without using a `collect` node which means it can be used inside an `iterate` node without any collecting on every iteration issues.
-
-- `ControlNet-Linked` - Collects ControlNet info to pass to other nodes.
-- `IP-Adapter-Linked` - Collects IP-Adapter info to pass to other nodes.
-- `T2I-Adapter-Linked` - Collects T2I-Adapter info to pass to other nodes.
-
-Note: These are inherited from the core nodes so any update to the core nodes should be reflected in these.
-
-**Node Link:** https://github.com/skunkworxdark/adapters-linked-nodes
-
---------------------------------
-### Autostereogram Nodes
-
-**Description:** Generate autostereogram images from a depth map. This is not a very practically useful node but more a 90s nostalgic indulgence as I used to love these images as a kid.
-
-**Node Link:** https://github.com/skunkworxdark/autostereogram_nodes
-
-**Example Usage:**
-
- -> ->
-
---------------------------------
-### Average Images
-
-**Description:** This node takes in a collection of images of the same size and averages them as output. It converts everything to RGB mode first.
-
-**Node Link:** https://github.com/JPPhoto/average-images-node
-
---------------------------------
-### Clean Image Artifacts After Cut
-
-Description: Removes residual artifacts after an image is separated from its background.
-
-Node Link: https://github.com/VeyDlin/clean-artifact-after-cut-node
-
-View:
-
-
---------------------------------
-### Close Color Mask
-
-Description: Generates a mask for images based on a closely matching color, useful for color-based selections.
-
-Node Link: https://github.com/VeyDlin/close-color-mask-node
-
-View:
-
-
---------------------------------
-### Clothing Mask
-
-Description: Employs a U2NET neural network trained for the segmentation of clothing items in images.
-
-Node Link: https://github.com/VeyDlin/clothing-mask-node
-
-View:
-
-
---------------------------------
-### Contrast Limited Adaptive Histogram Equalization
-
-Description: Enhances local image contrast using adaptive histogram equalization with contrast limiting.
-
-Node Link: https://github.com/VeyDlin/clahe-node
-
-View:
-
-
---------------------------------
-### Depth Map from Wavefront OBJ
-
-**Description:** Render depth maps from Wavefront .obj files (triangulated) using this simple 3D renderer utilizing numpy and matplotlib to compute and color the scene. There are simple parameters to change the FOV, camera position, and model orientation.
-
-To be imported, an .obj must use triangulated meshes, so make sure to enable that option if exporting from a 3D modeling program. This renderer makes each triangle a solid color based on its average depth, so it will cause anomalies if your .obj has large triangles. In Blender, the Remesh modifier can be helpful to subdivide a mesh into small pieces that work well given these limitations.
-
-**Node Link:** https://github.com/dwringer/depth-from-obj-node
-
-**Example Usage:**
-
-
---------------------------------
-### Film Grain
-
-**Description:** This node adds a film grain effect to the input image based on the weights, seeds, and blur radii parameters. It works with RGB input images only.
-
-**Node Link:** https://github.com/JPPhoto/film-grain-node
-
---------------------------------
-### Generative Grammar-Based Prompt Nodes
-
-**Description:** This set of 3 nodes generates prompts from simple user-defined grammar rules (loaded from custom files - examples provided below). The prompts are made by recursively expanding a special template string, replacing nonterminal "parts-of-speech" until no nonterminal terms remain in the string.
-
-This includes 3 Nodes:
-- *Lookup Table from File* - loads a YAML file "prompt" section (or of a whole folder of YAML's) into a JSON-ified dictionary (Lookups output)
-- *Lookups Entry from Prompt* - places a single entry in a new Lookups output under the specified heading
-- *Prompt from Lookup Table* - uses a Collection of Lookups as grammar rules from which to randomly generate prompts.
-
-**Node Link:** https://github.com/dwringer/generative-grammar-prompt-nodes
-
-**Example Usage:**
-
-
---------------------------------
-### GPT2RandomPromptMaker
-
-**Description:** A node for InvokeAI utilizes the GPT-2 language model to generate random prompts based on a provided seed and context.
-
-**Node Link:** https://github.com/mickr777/GPT2RandomPromptMaker
-
-**Output Examples**
-
-Generated Prompt: An enchanted weapon will be usable by any character regardless of their alignment.
-
-
-
---------------------------------
-### Grid to Gif
-
-**Description:** One node that turns a grid image into an image collection, one node that turns an image collection into a gif.
-
-**Node Link:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/GridToGif.py
-
-**Example Node Graph:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/Grid%20to%20Gif%20Example%20Workflow.json
-
-**Output Examples**
-
-
-
-
---------------------------------
-### Halftone
-
-**Description**: Halftone converts the source image to grayscale and then performs halftoning. CMYK Halftone converts the image to CMYK and applies a per-channel halftoning to make the source image look like a magazine or newspaper. For both nodes, you can specify angles and halftone dot spacing.
-
-**Node Link:** https://github.com/JPPhoto/halftone-node
-
-**Example**
-
-Input:
-
-
-
-Halftone Output:
-
-
-
-CMYK Halftone Output:
-
-
-
---------------------------------
-
-### Hand Refiner with MeshGraphormer
-
-**Description**: Hand Refiner takes in your image and automatically generates a fixed depth map for the hands along with a mask of the hands region that will conveniently allow you to use them along with ControlNet to fix the wonky hands generated by Stable Diffusion
-
-**Node Link:** https://github.com/blessedcoolant/invoke_meshgraphormer
-
-**View**
-
-
---------------------------------
-
-### Image and Mask Composition Pack
-
-**Description:** This is a pack of nodes for composing masks and images, including a simple text mask creator and both image and latent offset nodes. The offsets wrap around, so these can be used in conjunction with the Seamless node to progressively generate centered on different parts of the seamless tiling.
-
-This includes 15 Nodes:
-
-- *Adjust Image Hue Plus* - Rotate the hue of an image in one of several different color spaces.
-- *Blend Latents/Noise (Masked)* - Use a mask to blend part of one latents tensor [including Noise outputs] into another. Can be used to "renoise" sections during a multi-stage [masked] denoising process.
-- *Enhance Image* - Boost or reduce color saturation, contrast, brightness, sharpness, or invert colors of any image at any stage with this simple wrapper for pillow [PIL]'s ImageEnhance module.
-- *Equivalent Achromatic Lightness* - Calculates image lightness accounting for Helmholtz-Kohlrausch effect based on a method described by High, Green, and Nussbaum (2023).
-- *Text to Mask (Clipseg)* - Input a prompt and an image to generate a mask representing areas of the image matched by the prompt.
-- *Text to Mask Advanced (Clipseg)* - Output up to four prompt masks combined with logical "and", logical "or", or as separate channels of an RGBA image.
-- *Image Layer Blend* - Perform a layered blend of two images using alpha compositing. Opacity of top layer is selectable, with optional mask and several different blend modes/color spaces.
-- *Image Compositor* - Take a subject from an image with a flat backdrop and layer it on another image using a chroma key or flood select background removal.
-- *Image Dilate or Erode* - Dilate or expand a mask (or any image!). This is equivalent to an expand/contract operation.
-- *Image Value Thresholds* - Clip an image to pure black/white beyond specified thresholds.
-- *Offset Latents* - Offset a latents tensor in the vertical and/or horizontal dimensions, wrapping it around.
-- *Offset Image* - Offset an image in the vertical and/or horizontal dimensions, wrapping it around.
-- *Rotate/Flip Image* - Rotate an image in degrees clockwise/counterclockwise about its center, optionally resizing the image boundaries to fit, or flipping it about the vertical and/or horizontal axes.
-- *Shadows/Highlights/Midtones* - Extract three masks (with adjustable hard or soft thresholds) representing shadows, midtones, and highlights regions of an image.
-- *Text Mask (simple 2D)* - create and position a white on black (or black on white) line of text using any font locally available to Invoke.
-
-**Node Link:** https://github.com/dwringer/composition-nodes
-
-
-
---------------------------------
-### Image Dominant Color
-
-Description: Identifies and extracts the dominant color from an image using k-means clustering.
-
-Node Link: https://github.com/VeyDlin/image-dominant-color-node
-
-View:
-
-
---------------------------------
-### Image to Character Art Image Nodes
-
-**Description:** Group of nodes to convert an input image into ascii/unicode art Image
-
-**Node Link:** https://github.com/mickr777/imagetoasciiimage
-
-**Output Examples**
-
-
-
-
-
---------------------------------
-
-### Image Picker
-
-**Description:** This InvokeAI node takes in a collection of images and randomly chooses one. This can be useful when you have a number of poses to choose from for a ControlNet node, or a number of input images for another purpose.
-
-**Node Link:** https://github.com/JPPhoto/image-picker-node
-
---------------------------------
-### Image Resize Plus
-
-Description: Provides various image resizing options such as fill, stretch, fit, center, and crop.
-
-Node Link: https://github.com/VeyDlin/image-resize-plus-node
-
-View:
-
-
-
---------------------------------
-### Latent Upscale
-
-**Description:** This node uses a small (~2.4mb) model to upscale the latents used in a Stable Diffusion 1.5 or Stable Diffusion XL image generation, rather than the typical interpolation method, avoiding the traditional downsides of the latent upscale technique.
-
-**Node Link:** [https://github.com/gogurtenjoyer/latent-upscale](https://github.com/gogurtenjoyer/latent-upscale)
-
---------------------------------
-### Load Video Frame
-
-**Description:** This is a video frame image provider + indexer/video creation nodes for hooking up to iterators and ranges and ControlNets and such for invokeAI node experimentation. Think animation + ControlNet outputs.
-
-**Node Link:** https://github.com/helix4u/load_video_frame
-
-**Output Example:**
-
-
---------------------------------
-### Make 3D
-
-**Description:** Create compelling 3D stereo images from 2D originals.
-
-**Node Link:** [https://gitlab.com/srcrr/shift3d/-/raw/main/make3d.py](https://gitlab.com/srcrr/shift3d)
-
-**Example Node Graph:** https://gitlab.com/srcrr/shift3d/-/raw/main/example-workflow.json?ref_type=heads&inline=false
-
-**Output Examples**
-
-
-
-
---------------------------------
-### Mask Operations
-
-Description: Offers logical operations (OR, SUB, AND) for combining and manipulating image masks.
-
-Node Link: https://github.com/VeyDlin/mask-operations-node
-
-View:
-
-
---------------------------------
-### Match Histogram
-
-**Description:** An InvokeAI node to match a histogram from one image to another. This is a bit like the `color correct` node in the main InvokeAI but this works in the YCbCr colourspace and can handle images of different sizes. Also does not require a mask input.
-- Option to only transfer luminance channel.
-- Option to save output as grayscale
-
-A good use case for this node is to normalize the colors of an image that has been through the tiled scaling workflow of my XYGrid Nodes.
-
-See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md
-
-**Node Link:** https://github.com/skunkworxdark/match_histogram
-
-**Output Examples**
-
-
-
---------------------------------
-### Metadata Linked Nodes
-
-**Description:** A set of nodes for Metadata. Collect Metadata from within an `iterate` node & extract metadata from an image.
-
-- `Metadata Item Linked` - Allows collecting of metadata while within an iterate node with no need for a collect node or conversion to metadata node
-- `Metadata From Image` - Provides Metadata from an image
-- `Metadata To String` - Extracts a String value of a label from metadata
-- `Metadata To Integer` - Extracts an Integer value of a label from metadata
-- `Metadata To Float` - Extracts a Float value of a label from metadata
-- `Metadata To Scheduler` - Extracts a Scheduler value of a label from metadata
-- `Metadata To Bool` - Extracts Bool types from metadata
-- `Metadata To Model` - Extracts model types from metadata
-- `Metadata To SDXL Model` - Extracts SDXL model types from metadata
-- `Metadata To LoRAs` - Extracts Loras from metadata.
-- `Metadata To SDXL LoRAs` - Extracts SDXL Loras from metadata
-- `Metadata To ControlNets` - Extracts ControNets from metadata
-- `Metadata To IP-Adapters` - Extracts IP-Adapters from metadata
-- `Metadata To T2I-Adapters` - Extracts T2I-Adapters from metadata
-- `Denoise Latents + Metadata` - This is an inherited version of the existing `Denoise Latents` node but with a metadata input and output.
-
-**Node Link:** https://github.com/skunkworxdark/metadata-linked-nodes
-
---------------------------------
-### Negative Image
-
-Description: Creates a negative version of an image, effective for visual effects and mask inversion.
-
-Node Link: https://github.com/VeyDlin/negative-image-node
-
-View:
-
-
---------------------------------
-### Nightmare Promptgen
-
-**Description:** Nightmare Prompt Generator - Uses a local text generation model to create unique imaginative (but usually nightmarish) prompts for InvokeAI. By default, it allows you to choose from some gpt-neo models I finetuned on over 2500 of my own InvokeAI prompts in Compel format, but you're able to add your own, as well. Offers support for replacing any troublesome words with a random choice from list you can also define.
-
-**Node Link:** [https://github.com/gogurtenjoyer/nightmare-promptgen](https://github.com/gogurtenjoyer/nightmare-promptgen)
-
---------------------------------
-### Oobabooga
-
-**Description:** asks a local LLM running in Oobabooga's Text-Generation-Webui to write a prompt based on the user input.
-
-**Link:** https://github.com/sammyf/oobabooga-node
-
-**Example:**
-
-"describe a new mystical creature in its natural environment"
-
-*can return*
-
-"The mystical creature I am describing to you is called the "Glimmerwing". It is a majestic, iridescent being that inhabits the depths of the most enchanted forests and glimmering lakes. Its body is covered in shimmering scales that reflect every color of the rainbow, and it has delicate, translucent wings that sparkle like diamonds in the sunlight. The Glimmerwing's home is a crystal-clear lake, surrounded by towering trees with leaves that shimmer like jewels. In this serene environment, the Glimmerwing spends its days swimming gracefully through the water, chasing schools of glittering fish and playing with the gentle ripples of the lake's surface.
-As the sun sets, the Glimmerwing perches on a branch of one of the trees, spreading its wings to catch the last rays of light. The creature's scales glow softly, casting a rainbow of colors across the forest floor. The Glimmerwing sings a haunting melody, its voice echoing through the stillness of the night air. Its song is said to have the power to heal the sick and bring peace to troubled souls. Those who are lucky enough to hear the Glimmerwing's song are forever changed by its beauty and grace."
-
-
-
-**Requirement**
-
-a Text-Generation-Webui instance (might work remotely too, but I never tried it) and obviously InvokeAI 3.x
-
-**Note**
-
-This node works best with SDXL models, especially as the style can be described independently of the LLM's output.
-
---------------------------------
-### Prompt Tools
-
-**Description:** A set of InvokeAI nodes that add general prompt (string) manipulation tools. Designed to accompany the `Prompts From File` node and other prompt generation nodes.
-
-1. `Prompt To File` - saves a prompt or collection of prompts to a file. one per line. There is an append/overwrite option.
-2. `PTFields Collect` - Converts image generation fields into a Json format string that can be passed to Prompt to file.
-3. `PTFields Expand` - Takes Json string and converts it to individual generation parameters. This can be fed from the Prompts to file node.
-4. `Prompt Strength` - Formats prompt with strength like the weighted format of compel
-5. `Prompt Strength Combine` - Combines weighted prompts for .and()/.blend()
-6. `CSV To Index String` - Gets a string from a CSV by index. Includes a Random index option
-
-The following Nodes are now included in v3.2 of Invoke and are nolonger in this set of tools.
-- `Prompt Join` -> `String Join`
-- `Prompt Join Three` -> `String Join Three`
-- `Prompt Replace` -> `String Replace`
-- `Prompt Split Neg` -> `String Split Neg`
-
-
-See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md
-
-**Node Link:** https://github.com/skunkworxdark/Prompt-tools-nodes
-
-**Workflow Examples**
-
-
-
---------------------------------
-### Remote Image
-
-**Description:** This is a pack of nodes to interoperate with other services, be they public websites or bespoke local servers. The pack consists of these nodes:
-
-- *Load Remote Image* - Lets you load remote images such as a realtime webcam image, an image of the day, or dynamically created images.
-- *Post Image to Remote Server* - Lets you upload an image to a remote server using an HTTP POST request, eg for storage, display or further processing.
-
-**Node Link:** https://github.com/fieldOfView/InvokeAI-remote_image
-
---------------------------------
-
-### BriaAI Remove Background
-
-**Description**: Implements one click background removal with BriaAI's new version 1.4 model which seems to be be producing better results than any other previous background removal tool.
-
-**Node Link:** https://github.com/blessedcoolant/invoke_bria_rmbg
-
-**View**
-
-
---------------------------------
-### Remove Background
-
-Description: An integration of the rembg package to remove backgrounds from images using multiple U2NET models.
-
-Node Link: https://github.com/VeyDlin/remove-background-node
-
-View:
-
-
---------------------------------
-### Retroize
-
-**Description:** Retroize is a collection of nodes for InvokeAI to "Retroize" images. Any image can be given a fresh coat of retro paint with these nodes, either from your gallery or from within the graph itself. It includes nodes to pixelize, quantize, palettize, and ditherize images; as well as to retrieve palettes from existing images.
-
-**Node Link:** https://github.com/Ar7ific1al/invokeai-retroizeinode/
-
-**Retroize Output Examples**
-
-
-
---------------------------------
-### Simple Skin Detection
-
-Description: Detects skin in images based on predefined color thresholds.
-
-Node Link: https://github.com/VeyDlin/simple-skin-detection-node
-
-View:
-
-
-
---------------------------------
-### Size Stepper Nodes
-
-**Description:** This is a set of nodes for calculating the necessary size increments for doing upscaling workflows. Use the *Final Size & Orientation* node to enter your full size dimensions and orientation (portrait/landscape/random), then plug that and your initial generation dimensions into the *Ideal Size Stepper* and get 1, 2, or 3 intermediate pairs of dimensions for upscaling. Note this does not output the initial size or full size dimensions: the 1, 2, or 3 outputs of this node are only the intermediate sizes.
-
-A third node is included, *Random Switch (Integers)*, which is just a generic version of Final Size with no orientation selection.
-
-**Node Link:** https://github.com/dwringer/size-stepper-nodes
-
-**Example Usage:**
-
-
---------------------------------
-### Text font to Image
-
-**Description:** text font to text image node for InvokeAI, download a font to use (or if in font cache uses it from there), the text is always resized to the image size, but can control that with padding, optional 2nd line
-
-**Node Link:** https://github.com/mickr777/textfontimage
-
-**Output Examples**
-
-
-
-Results after using the depth controlnet
-
-
-
-
-
---------------------------------
-### Thresholding
-
-**Description:** This node generates masks for highlights, midtones, and shadows given an input image. You can optionally specify a blur for the lookup table used in making those masks from the source image.
-
-**Node Link:** https://github.com/JPPhoto/thresholding-node
-
-**Examples**
-
-Input:
-
-
-
-Highlights/Midtones/Shadows:
-
-
-
-
-
-Highlights/Midtones/Shadows (with LUT blur enabled):
-
-
-
-
-
---------------------------------
-### Unsharp Mask
-
-**Description:** Applies an unsharp mask filter to an image, preserving its alpha channel in the process.
-
-**Node Link:** https://github.com/JPPhoto/unsharp-mask-node
-
---------------------------------
-### XY Image to Grid and Images to Grids nodes
-
-**Description:** These nodes add the following to InvokeAI:
-- Generate grids of images from multiple input images
-- Create XY grid images with labels from parameters
-- Split images into overlapping tiles for processing (for super-resolution workflows)
-- Recombine image tiles into a single output image blending the seams
-
-The nodes include:
-1. `Images To Grids` - Combine multiple images into a grid of images
-2. `XYImage To Grid` - Take X & Y params and creates a labeled image grid.
-3. `XYImage Tiles` - Super-resolution (embiggen) style tiled resizing
-4. `Image Tot XYImages` - Takes an image and cuts it up into a number of columns and rows.
-5. Multiple supporting nodes - Helper nodes for data wrangling and building `XYImage` collections
-
-See full docs here: https://github.com/skunkworxdark/XYGrid_nodes/edit/main/README.md
-
-**Node Link:** https://github.com/skunkworxdark/XYGrid_nodes
-
-**Output Examples**
-
-
-
-
---------------------------------
-### Example Node Template
-
-**Description:** This node allows you to do super cool things with InvokeAI.
-
-**Node Link:** https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/app/invocations/prompt.py
-
-**Example Workflow:** https://github.com/invoke-ai/InvokeAI/blob/docs/main/docs/workflows/Prompt_from_File.json
-
-**Output Examples**
-
-
-
-
-## Disclaimer
-
-The nodes linked have been developed and contributed by members of the Invoke AI community. While we strive to ensure the quality and safety of these contributions, we do not guarantee the reliability or security of the nodes. If you have issues or concerns with any of the nodes below, please raise it on GitHub or in the Discord.
-
-
-## Help
-If you run into any issues with a node, please post in the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy).
-
diff --git a/docs/nodes/contributingNodes.md b/docs/nodes/contributingNodes.md
deleted file mode 100644
index 7a30c8aeb0f..00000000000
--- a/docs/nodes/contributingNodes.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Contributing Nodes
-
-To learn about the specifics of creating a new node, please visit our [Node creation documentation](../contributing/INVOCATIONS.md).
-
-Once you’ve created a node and confirmed that it behaves as expected locally, follow these steps:
-
-- 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.
-- 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
- - 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.
-- A maintainer will review the pull request and node. If the node is aligned with the direction of the project, you may be asked for permission to include it in the core project.
-
-### Community Node Template
-
-```markdown
---------------------------------
-### Super Cool Node Template
-
-**Description:** This node allows you to do super cool things with InvokeAI.
-
-**Node Link:** https://github.com/invoke-ai/InvokeAI/fake_node.py
-
-**Example Node Graph:** https://github.com/invoke-ai/InvokeAI/fake_node_graph.json
-
-**Output Examples**
-
-
-```
diff --git a/docs/nodes/defaultNodes.md b/docs/nodes/defaultNodes.md
deleted file mode 100644
index b78c9af9010..00000000000
--- a/docs/nodes/defaultNodes.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# List of Default Nodes
-
-The table below contains a list of the default nodes shipped with InvokeAI and
-their descriptions.
-
-| Node | Function |
-| :------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Add Integers | Adds two numbers |
-| Boolean Primitive Collection | A collection of boolean primitive values |
-| Boolean Primitive | A boolean primitive value |
-| Canny Processor | Canny edge detection for ControlNet |
-| CenterPadCrop | Pad or crop an image's sides from the center by specified pixels. Positive values are outside of the image. |
-| CLIP Skip | Skip layers in clip text_encoder model. |
-| Collect | Collects values into a collection |
-| Color Correct | Shifts the colors of a target image to match the reference image, optionally using a mask to only color-correct certain regions of the target image. |
-| Color Primitive | A color primitive value |
-| Compel Prompt | Parse prompt using compel package to conditioning. |
-| Conditioning Primitive Collection | A collection of conditioning tensor primitive values |
-| Conditioning Primitive | A conditioning tensor primitive value |
-| Content Shuffle Processor | Applies content shuffle processing to image |
-| ControlNet | Collects ControlNet info to pass to other nodes |
-| Create Denoise Mask | Converts a greyscale or transparency image into a mask for denoising. |
-| Create Gradient Mask | Creates a mask for Gradient ("soft", "differential") inpainting that gradually expands during denoising. Improves edge coherence. |
-| Denoise Latents | Denoises noisy latents to decodable images |
-| Divide Integers | Divides two numbers |
-| Dynamic Prompt | Parses a prompt using adieyal/dynamicprompts' random or combinatorial generator |
-| [FaceMask](./detailedNodes/faceTools.md#facemask) | Generates masks for faces in an image to use with Inpainting |
-| [FaceIdentifier](./detailedNodes/faceTools.md#faceidentifier) | Identifies and labels faces in an image |
-| [FaceOff](./detailedNodes/faceTools.md#faceoff) | Creates a new image that is a scaled bounding box with a mask on the face for Inpainting |
-| Float Math | Perform basic math operations on two floats |
-| Float Primitive Collection | A collection of float primitive values |
-| Float Primitive | A float primitive value |
-| Float Range | Creates a range |
-| HED (softedge) Processor | Applies HED edge detection to image |
-| Blur Image | Blurs an image |
-| Extract Image Channel | Gets a channel from an image. |
-| Image Primitive Collection | A collection of image primitive values |
-| Integer Math | Perform basic math operations on two integers |
-| Convert Image Mode | Converts an image to a different mode. |
-| Crop Image | Crops an image to a specified box. The box can be outside of the image. |
-| Ideal Size | Calculates an ideal image size for latents for a first pass of a multi-pass upscaling to avoid duplication and other artifacts |
-| Image Hue Adjustment | Adjusts the Hue of an image. |
-| Inverse Lerp Image | Inverse linear interpolation of all pixels of an image |
-| Image Primitive | An image primitive value |
-| Lerp Image | Linear interpolation of all pixels of an image |
-| Offset Image Channel | Add to or subtract from an image color channel by a uniform value. |
-| Multiply Image Channel | Multiply or Invert an image color channel by a scalar value. |
-| Multiply Images | Multiplies two images together using `PIL.ImageChops.multiply()`. |
-| Blur NSFW Image | Add blur to NSFW-flagged images |
-| Paste Image | Pastes an image into another image. |
-| ImageProcessor | Base class for invocations that preprocess images for ControlNet |
-| Resize Image | Resizes an image to specific dimensions |
-| Round Float | Rounds a float to a specified number of decimal places |
-| Float to Integer | Converts a float to an integer. Optionally rounds to an even multiple of a input number. |
-| Scale Image | Scales an image by a factor |
-| Image to Latents | Encodes an image into latents. |
-| Add Invisible Watermark | Add an invisible watermark to an image |
-| Solid Color Infill | Infills transparent areas of an image with a solid color |
-| PatchMatch Infill | Infills transparent areas of an image using the PatchMatch algorithm |
-| Tile Infill | Infills transparent areas of an image with tiles of the image |
-| Integer Primitive Collection | A collection of integer primitive values |
-| Integer Primitive | An integer primitive value |
-| Iterate | Iterates over a list of items |
-| Latents Primitive Collection | A collection of latents tensor primitive values |
-| Latents Primitive | A latents tensor primitive value |
-| Latents to Image | Generates an image from latents. |
-| Leres (Depth) Processor | Applies leres processing to image |
-| Lineart Anime Processor | Applies line art anime processing to image |
-| Lineart Processor | Applies line art processing to image |
-| LoRA Loader | Apply selected lora to unet and text_encoder. |
-| Main Model Loader | Loads a main model, outputting its submodels. |
-| Combine Mask | Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`. |
-| Mask Edge | Applies an edge mask to an image |
-| Mask from Alpha | Extracts the alpha channel of an image as a mask. |
-| Mediapipe Face Processor | Applies mediapipe face processing to image |
-| Midas (Depth) Processor | Applies Midas depth processing to image |
-| MLSD Processor | Applies MLSD processing to image |
-| Multiply Integers | Multiplies two numbers |
-| Noise | Generates latent noise. |
-| Normal BAE Processor | Applies NormalBae processing to image |
-| ONNX Latents to Image | Generates an image from latents. |
-| ONNX Prompt (Raw) | A node to process inputs and produce outputs. May use dependency injection in **init** to receive providers. |
-| ONNX Text to Latents | Generates latents from conditionings. |
-| ONNX Model Loader | Loads a main model, outputting its submodels. |
-| OpenCV Inpaint | Simple inpaint using opencv. |
-| DW Openpose Processor | Applies Openpose processing to image |
-| PIDI Processor | Applies PIDI processing to image |
-| Prompts from File | Loads prompts from a text file |
-| Random Integer | Outputs a single random integer. |
-| Random Range | Creates a collection of random numbers |
-| Integer Range | Creates a range of numbers from start to stop with step |
-| Integer Range of Size | Creates a range from start to start + size with step |
-| Resize Latents | Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8. |
-| SDXL Compel Prompt | Parse prompt using compel package to conditioning. |
-| SDXL LoRA Loader | Apply selected lora to unet and text_encoder. |
-| SDXL Main Model Loader | Loads an sdxl base model, outputting its submodels. |
-| SDXL Refiner Compel Prompt | Parse prompt using compel package to conditioning. |
-| SDXL Refiner Model Loader | Loads an sdxl refiner model, outputting its submodels. |
-| Scale Latents | Scales latents by a given factor. |
-| Segment Anything Processor | Applies segment anything processing to image |
-| Show Image | Displays a provided image, and passes it forward in the pipeline. |
-| Step Param Easing | Experimental per-step parameter easing for denoising steps |
-| String Primitive Collection | A collection of string primitive values |
-| String Primitive | A string primitive value |
-| Subtract Integers | Subtracts two numbers |
-| Tile Resample Processor | Tile resampler processor |
-| Upscale (RealESRGAN) | Upscales an image using RealESRGAN. |
-| VAE Loader | Loads a VAE model, outputting a VaeLoaderOutput |
-| Zoe (Depth) Processor | Applies Zoe depth processing to image |
diff --git a/docs/nodes/detailedNodes/faceTools.md b/docs/nodes/detailedNodes/faceTools.md
deleted file mode 100644
index 632212d3c33..00000000000
--- a/docs/nodes/detailedNodes/faceTools.md
+++ /dev/null
@@ -1,154 +0,0 @@
-# Face Nodes
-
-## FaceOff
-
-FaceOff mimics a user finding a face in an image and resizing the bounding box
-around the head in Canvas.
-
-Enter a face ID (found with FaceIdentifier) to choose which face to mask.
-
-Just as you would add more context inside the bounding box by making it larger
-in Canvas, the node gives you a padding input (in pixels) which will
-simultaneously add more context, and increase the resolution of the bounding box
-so the face remains the same size inside it.
-
-The "Minimum Confidence" input defaults to 0.5 (50%), and represents a pass/fail
-threshold a detected face must reach for it to be processed. Lowering this value
-may help if detection is failing. If the detected masks are imperfect and stray
-too far outside/inside of faces, the node gives you X & Y offsets to shrink/grow
-the masks by a multiplier.
-
-FaceOff will output the face in a bounded image, taking the face off of the
-original image for input into any node that accepts image inputs. The node also
-outputs a face mask with the dimensions of the bounded image. The X & Y outputs
-are for connecting to the X & Y inputs of the Paste Image node, which will place
-the bounded image back on the original image using these coordinates.
-
-###### Inputs/Outputs
-
-| Input | Description |
-| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Image | Image for face detection |
-| Face ID | The face ID to process, numbered from 0. Multiple faces not supported. Find a face's ID with FaceIdentifier node. |
-| Minimum Confidence | Minimum confidence for face detection (lower if detection is failing) |
-| X Offset | X-axis offset of the mask |
-| Y Offset | Y-axis offset of the mask |
-| Padding | All-axis padding around the mask in pixels |
-| Chunk | Chunk (or divide) the image into sections to greatly improve face detection success. Defaults to off, but will activate if no faces are detected normally. Activate to chunk by default. |
-
-| Output | Description |
-| ------------- | ------------------------------------------------ |
-| Bounded Image | Original image bound, cropped, and resized |
-| Width | The width of the bounded image in pixels |
-| Height | The height of the bounded image in pixels |
-| Mask | The output mask |
-| X | The x coordinate of the bounding box's left side |
-| Y | The y coordinate of the bounding box's top side |
-
-## FaceMask
-
-FaceMask mimics a user drawing masks on faces in an image in Canvas.
-
-The "Face IDs" input allows the user to select specific faces to be masked.
-Leave empty to detect and mask all faces, or a comma-separated list for a
-specific combination of faces (ex: `1,2,4`). A single integer will detect and
-mask that specific face. Find face IDs with the FaceIdentifier node.
-
-The "Minimum Confidence" input defaults to 0.5 (50%), and represents a pass/fail
-threshold a detected face must reach for it to be processed. Lowering this value
-may help if detection is failing.
-
-If the detected masks are imperfect and stray too far outside/inside of faces,
-the node gives you X & Y offsets to shrink/grow the masks by a multiplier. All
-masks shrink/grow together by the X & Y offset values.
-
-By default, masks are created to change faces. When masks are inverted, they
-change surrounding areas, protecting faces.
-
-###### Inputs/Outputs
-
-| Input | Description |
-| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Image | Image for face detection |
-| Face IDs | Comma-separated list of face ids to mask eg '0,2,7'. Numbered from 0. Leave empty to mask all. Find face IDs with FaceIdentifier node. |
-| Minimum Confidence | Minimum confidence for face detection (lower if detection is failing) |
-| X Offset | X-axis offset of the mask |
-| Y Offset | Y-axis offset of the mask |
-| Chunk | Chunk (or divide) the image into sections to greatly improve face detection success. Defaults to off, but will activate if no faces are detected normally. Activate to chunk by default. |
-| Invert Mask | Toggle to invert the face mask |
-
-| Output | Description |
-| ------ | --------------------------------- |
-| Image | The original image |
-| Width | The width of the image in pixels |
-| Height | The height of the image in pixels |
-| Mask | The output face mask |
-
-## FaceIdentifier
-
-FaceIdentifier outputs an image with detected face IDs printed in white numbers
-onto each face.
-
-Face IDs can then be used in FaceMask and FaceOff to selectively mask all, a
-specific combination, or single faces.
-
-The FaceIdentifier output image is generated for user reference, and isn't meant
-to be passed on to other image-processing nodes.
-
-The "Minimum Confidence" input defaults to 0.5 (50%), and represents a pass/fail
-threshold a detected face must reach for it to be processed. Lowering this value
-may help if detection is failing. If an image is changed in the slightest, run
-it through FaceIdentifier again to get updated FaceIDs.
-
-###### Inputs/Outputs
-
-| Input | Description |
-| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Image | Image for face detection |
-| Minimum Confidence | Minimum confidence for face detection (lower if detection is failing) |
-| Chunk | Chunk (or divide) the image into sections to greatly improve face detection success. Defaults to off, but will activate if no faces are detected normally. Activate to chunk by default. |
-
-| Output | Description |
-| ------ | ------------------------------------------------------------------------------------------------ |
-| Image | The original image with small face ID numbers printed in white onto each face for user reference |
-| Width | The width of the original image in pixels |
-| Height | The height of the original image in pixels |
-
-## Tips
-
-- If not all target faces are being detected, activate Chunk to bypass full
- image face detection and greatly improve detection success.
-- Final results will vary between full-image detection and chunking for faces
- that are detectable by both due to the nature of the process. Try either to
- your taste.
-- Be sure Minimum Confidence is set the same when using FaceIdentifier with
- FaceOff/FaceMask.
-- For FaceOff, use the color correction node before faceplace to correct edges
- being noticeable in the final image (see example screenshot).
-- Non-inpainting models may struggle to paint/generate correctly around faces.
-- If your face won't change the way you want it to no matter what you change,
- consider that the change you're trying to make is too much at that resolution.
- For example, if an image is only 512x768 total, the face might only be 128x128
- or 256x256, much smaller than the 512x512 your SD1.5 model was probably
- trained on. Try increasing the resolution of the image by upscaling or
- resizing, add padding to increase the bounding box's resolution, or use an
- image where the face takes up more pixels.
-- If the resulting face seems out of place pasted back on the original image
- (ie. too large, not proportional), add more padding on the FaceOff node to
- give inpainting more context. Context and good prompting are important to
- keeping things proportional.
-- If you find the mask is too big/small and going too far outside/inside the
- area you want to affect, adjust the x & y offsets to shrink/grow the mask area
-- Use a higher denoise start value to resemble aspects of the original face or
- surroundings. Denoise start = 0 & denoise end = 1 will make something new,
- while denoise start = 0.50 & denoise end = 1 will be 50% old and 50% new.
-- mediapipe isn't good at detecting faces with lots of face paint, hair covering
- the face, etc. Anything that obstructs the face will likely result in no faces
- being detected.
-- If you find your face isn't being detected, try lowering the minimum
- confidence value from 0.5. This could result in false positives, however
- (random areas being detected as faces and masked).
-- After altering an image and wanting to process a different face in the newly
- altered image, run the altered image through FaceIdentifier again to see the
- new Face IDs. MediaPipe will most likely detect faces in a different order
- after an image has been changed in the slightest.
diff --git a/docs/nodes/exampleWorkflows.md b/docs/nodes/exampleWorkflows.md
deleted file mode 100644
index b52493f365f..00000000000
--- a/docs/nodes/exampleWorkflows.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Example Workflows
-
-We've curated some example workflows for you to get started with Workflows in InvokeAI! These can also be found in the Workflow Library, located in the Workflow Editor of Invoke.
-
-To use them, right click on your desired workflow, follow the link to GitHub and click the "⬇" button to download the raw file. You can then use the "Load Workflow" functionality in InvokeAI to load the workflow and start generating images!
-
-If you're interested in finding more workflows, checkout the [#share-your-workflows](https://discord.com/channels/1020123559063990373/1130291608097661000) channel in the InvokeAI Discord.
-
-* [SD1.5 / SD2 Text to Image](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/Text_to_Image.json)
-* [SDXL Text to Image](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/SDXL_Text_to_Image.json)
-* [SDXL Text to Image with Refiner](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/SDXL_w_Refiner_Text_to_Image.json)
-* [Multi ControlNet (Canny & Depth)](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/Multi_ControlNet_Canny_and_Depth.json)
-* [Tiled Upscaling with ControlNet](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/ESRGAN_img2img_upscale_w_Canny_ControlNet.json)
-* [Prompt From File](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/Prompt_from_File.json)
-* [Face Detailer with IP-Adapter & ControlNet](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/Face_Detailer_with_IP-Adapter_and_Canny.json)
-* [FaceMask](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/FaceMask.json)
-* [FaceOff with 2x Face Scaling](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/FaceOff_FaceScale2x.json)
-* [QR Code Monster](https://github.com/invoke-ai/InvokeAI/blob/main/docs/workflows/QR_Code_Monster.json)
diff --git a/docs/nodes/overview.md b/docs/nodes/overview.md
deleted file mode 100644
index 71e316c8561..00000000000
--- a/docs/nodes/overview.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Nodes
-
-## What are Nodes?
-An Node is simply a single operation that takes in inputs and returns
-out outputs. Multiple nodes can be linked together to create more
-complex functionality. All InvokeAI features are added through nodes.
-
-### Anatomy of a Node
-
-Individual nodes are made up of the following:
-
-- Inputs: Edge points on the left side of the node window where you connect outputs from other nodes.
-- Outputs: Edge points on the right side of the node window where you connect to inputs on other nodes.
-- Options: Various options which are either manually configured, or overridden by connecting an output from another node to the input.
-
-
-With nodes, you can can easily extend the image generation capabilities of InvokeAI, and allow you build workflows that suit your needs.
-
-You can read more about nodes and the node editor [here](../nodes/NODES.md).
-
-To get started with nodes, take a look at some of our examples for [common workflows](../nodes/exampleWorkflows.md)
-
-## Downloading New Nodes
-To download a new node, visit our list of [Community Nodes](../nodes/communityNodes.md). These are nodes that have been created by the community, for the community.
-
-
diff --git a/docs/other/CONTRIBUTORS.md b/docs/other/CONTRIBUTORS.md
deleted file mode 100644
index 448eddbcf01..00000000000
--- a/docs/other/CONTRIBUTORS.md
+++ /dev/null
@@ -1,380 +0,0 @@
----
-title: Contributors
----
-
-# :octicons-person-24: Contributors
-
-The list of all the amazing people who have contributed to the various features that you get to
-experience in this fork.
-
-We thank them for all of their time and hard work.
-
-## **Original Author**
-
-- [Lincoln D. Stein](mailto:lincoln.stein@gmail.com)
-
-## **Current Core Team**
-
-* @lstein (Lincoln Stein) - Co-maintainer
-* @blessedcoolant - Co-maintainer
-* @hipsterusername (Kent Keirsey) - Co-maintainer, CEO, Positive Vibes
-* @psychedelicious (Spencer Mabrito) - Web Team Leader
-* @chainchompa (Jennifer Player) - Web Development & Chain-Chomping
-* @josh is toast (Josh Corbett) - Web Development
-* @cheerio (Mary Rogers) - Lead Engineer & Web App Development
-* @ebr (Eugene Brodsky) - Cloud/DevOps/Sofware engineer; your friendly neighbourhood cluster-autoscaler
-* @sunija - Standalone version
-* @genomancer (Gregg Helt) - Controlnet support
-* @brandon (Brandon Rising) - Platform, Infrastructure, Backend Systems
-* @ryanjdick (Ryan Dick) - Machine Learning & Training
-* @JPPhoto - Core image generation nodes
-* @dunkeroni - Image generation backend
-* @SkunkWorxDark - Image generation backend
-* @keturn (Kevin Turner) - Diffusers
-* @millu (Millun Atluri) - Community Wizard, Documentation, Node-wrangler,
-* @glimmerleaf (Devon Hopkins) - Community Wizard
-* @gogurt enjoyer - Discord moderator and end user support
-* @whosawhatsis - Discord moderator and end user support
-* @dwinrger - Discord moderator and end user support
-* @526christian - Discord moderator and end user support
-* @harvester62 - Discord moderator and end user support
-
-
-## **Honored Team Alumni**
-
-* @StAlKeR7779 (Sergey Borisov) - Torch stack, ONNX, model management, optimization
-* @damian0815 - Attention Systems and Compel Maintainer
-* @netsvetaev (Artur) - Localization support
-* @Kyle0654 (Kyle Schouviller) - Node Architect and General Backend Wizard
-* @tildebyte - Installation and configuration
-* @mauwii (Matthias Wilde) - Installation, release, continuous integration
-
-
-## **Full List of Contributors by Commit Name**
-
-- 이승석
-- AbdBarho
-- ablattmann
-- AdamOStark
-- Adam Rice
-- Airton Silva
-- Aldo Hoeben
-- Alexander Eichhorn
-- Alexandre D. Roberge
-- Alexandre Macabies
-- Alfie John
-- Andreas Rozek
-- Andre LaBranche
-- Andy Bearman
-- Andy Luhrs
-- Andy Pilate
-- Anonymous
-- Anthony Monthe
-- Any-Winter-4079
-- apolinario
-- Ar7ific1al
-- ArDiouscuros
-- Armando C. Santisbon
-- Arnold Cordewiner
-- Arthur Holstvoogd
-- artmen1516
-- Artur
-- Arturo Mendivil
-- Ben Alkov
-- Benjamin Warner
-- Bernard Maltais
-- blessedcoolant
-- blhook
-- BlueAmulet
-- Bouncyknighter
-- Brandon
-- Brandon Rising
-- Brent Ozar
-- Brian Racer
-- bsilvereagle
-- c67e708d
-- camenduru
-- CapableWeb
-- Carson Katri
-- chainchompa
-- Chloe
-- Chris Dawson
-- Chris Hayes
-- Chris Jones
-- chromaticist
-- Claus F. Strasburger
-- cmdr2
-- cody
-- Conor Reid
-- Cora Johnson-Roberson
-- coreco
-- cosmii02
-- cpacker
-- Cragin Godley
-- creachec
-- CrypticWit
-- d8ahazard
-- damian
-- damian0815
-- Damian at mba
-- Damian Stewart
-- Daniel Manzke
-- Danny Beer
-- Dan Sully
-- Darren Ringer
-- David Burnett
-- David Ford
-- David Regla
-- David Sisco
-- David Wager
-- Daya Adianto
-- db3000
-- DekitaRPG
-- Denis Olshin
-- Dennis
-- dependabot[bot]
-- Dmitry Parnas
-- Dobrynia100
-- Dominic Letz
-- DrGunnarMallon
-- Drun555
-- dunkeroni
-- Edward Johan
-- elliotsayes
-- Elrik
-- ElrikUnderlake
-- Eric Khun
-- Eric Wolf
-- Eugene
-- Eugene Brodsky
-- ExperimentalCyborg
-- Fabian Bahl
-- Fabio 'MrWHO' Torchetti
-- Fattire
-- fattire
-- Felipe Nogueira
-- Félix Sanz
-- figgefigge
-- Gabriel Mackievicz Telles
-- gabrielrotbart
-- gallegonovato
-- Gérald LONLAS
-- Gille
-- GitHub Actions Bot
-- glibesyck
-- gogurtenjoyer
-- Gohsuke Shimada
-- greatwolf
-- greentext2
-- Gregg Helt
-- H4rk
-- Håvard Gulldahl
-- henry
-- Henry van Megen
-- hipsterusername
-- hj
-- Hosted Weblate
-- Iman Karim
-- ismail ihsan bülbül
-- ItzAttila
-- Ivan Efimov
-- jakehl
-- Jakub Kolčář
-- JamDon2
-- James Reynolds
-- Jan Skurovec
-- Jari Vetoniemi
-- Jason Toffaletti
-- Jaulustus
-- Jeff Mahoney
-- Jennifer Player
-- jeremy
-- Jeremy Clark
-- JigenD
-- Jim Hays
-- Johan Roxendal
-- Johnathon Selstad
-- Jonathan
-- Jordan Hewitt
-- Joseph Dries III
-- Josh Corbett
-- JPPhoto
-- jspraul
-- junzi
-- Justin Wong
-- Juuso V
-- Kaspar Emanuel
-- Katsuyuki-Karasawa
-- Keerigan45
-- Kent Keirsey
-- Kevin Brack
-- Kevin Coakley
-- Kevin Gibbons
-- Kevin Schaul
-- Kevin Turner
-- Kieran Klaassen
-- krummrey
-- Kyle
-- Kyle Lacy
-- Kyle Schouviller
-- Lawrence Norton
-- LemonDouble
-- Leo Pasanen
-- Lincoln Stein
-- LoganPederson
-- Lynne Whitehorn
-- majick
-- Marco Labarile
-- Marta Nahorniuk
-- Martin Kristiansen
-- Mary Hipp
-- maryhipp
-- Mary Hipp Rogers
-- mastercaster
-- mastercaster9000
-- Matthias Wild
-- mauwii
-- michaelk71
-- mickr777
-- Mihai
-- Mihail Dumitrescu
-- Mikhail Tishin
-- Millun Atluri
-- Minjune Song
-- Mitchell Allain
-- mitien
-- mofuzz
-- Muhammad Usama
-- Name
-- _nderscore
-- Neil Wang
-- nekowaiz
-- nemuruibai
-- Netzer R
-- Nicholas Koh
-- Nicholas Körfer
-- nicolai256
-- Niek van der Maas
-- noodlebox
-- Nuno Coração
-- ofirkris
-- Olivier Louvignes
-- owenvincent
-- pand4z31
-- Patrick Esser
-- Patrick Tien
-- Patrick von Platen
-- Paul Curry
-- Paul Sajna
-- pejotr
-- Peter Baylies
-- Peter Lin
-- plucked
-- prixt
-- psychedelicious
-- psychedelicious@windows
-- Rainer Bernhardt
-- Riccardo Giovanetti
-- Rich Jones
-- rmagur1203
-- Rob Baines
-- Robert Bolender
-- Robin Rombach
-- Rohan Barar
-- Rohinish
-- rpagliuca
-- rromb
-- Rupesh Sreeraman
-- Ryan
-- Ryan Cao
-- Ryan Dick
-- Saifeddine
-- Saifeddine ALOUI
-- Sam
-- SammCheese
-- Sam McLeod
-- Sammy
-- sammyf
-- Samuel Husso
-- Saurav Maheshkar
-- Scott Lahteine
-- Sean McLellan
-- Sebastian Aigner
-- Sergey Borisov
-- Sergey Krashevich
-- Shapor Naghibzadeh
-- Shawn Zhong
-- Simona Liliac
-- Simon Vans-Colina
-- skunkworxdark
-- slashtechno
-- SoheilRezaei
-- Song, Pengcheng
-- spezialspezial
-- ssantos
-- StAlKeR7779
-- Stefan Tobler
-- Stephan Koglin-Fischer
-- SteveCaruso
-- Steve Martinelli
-- Steven Frank
-- Surisen
-- System X - Files
-- Taylor Kems
-- techicode
-- techybrain-dev
-- tesseractcat
-- thealanle
-- Thomas
-- tildebyte
-- Tim Cabbage
-- Tom
-- Tom Elovi Spruce
-- Tom Gouville
-- tomosuto
-- Travco
-- Travis Palmer
-- tyler
-- unknown
-- user1
-- vedant-3010
-- Vedant Madane
-- veprogames
-- wa.code
-- wfng92
-- whjms
-- whosawhatsis
-- Will
-- William Becher
-- William Chong
-- Wilson E. Alvarez
-- woweenie
-- Wubbbi
-- xra
-- Yeung Yiu Hung
-- ymgenesis
-- Yorzaren
-- Yosuke Shinya
-- yun saki
-- ZachNagengast
-- Zadagu
-- zeptofine
-- Zerdoumi
-- Васянатор
-- 冯不游
-- 唐澤 克幸
-
-## **Original CompVis (Stable Diffusion) Authors**
-
-- [Robin Rombach](https://github.com/rromb)
-- [Patrick von Platen](https://github.com/patrickvonplaten)
-- [ablattmann](https://github.com/ablattmann)
-- [Patrick Esser](https://github.com/pesser)
-- [owenvincent](https://github.com/owenvincent)
-- [apolinario](https://github.com/apolinario)
-- [Charles Packer](https://github.com/cpacker)
-
----
-
-_If you have contributed and don't see your name on the list of contributors, please let one of the
-collaborators know about the omission, or feel free to make a pull request._
diff --git a/docs/other/README-CompViz.md b/docs/other/README-CompViz.md
deleted file mode 100644
index cb3a207b2e0..00000000000
--- a/docs/other/README-CompViz.md
+++ /dev/null
@@ -1,255 +0,0 @@
----
-title: CompViz-Readme
----
-
-# _README from [CompViz/stable-diffusion](https://github.com/CompVis/stable-diffusion)_
-
-_Stable Diffusion was made possible thanks to a collaboration with
-[Stability AI](https://stability.ai/) and [Runway](https://runwayml.com/) and
-builds upon our previous work:_
-
-[**High-Resolution Image Synthesis with Latent Diffusion Models**](https://ommer-lab.com/research/latent-diffusion-models/)
-[Robin Rombach](https://github.com/rromb)\*,
-[Andreas Blattmann](https://github.com/ablattmann)\*,
-[Dominik Lorenz](https://github.com/qp-qp)\,
-[Patrick Esser](https://github.com/pesser),
-[Björn Ommer](https://hci.iwr.uni-heidelberg.de/Staff/bommer)
-
-## **CVPR '22 Oral**
-
-which is available on [GitHub](https://github.com/CompVis/latent-diffusion). PDF
-at [arXiv](https://arxiv.org/abs/2112.10752). Please also visit our
-[Project page](https://ommer-lab.com/research/latent-diffusion-models/).
-
-
-[Stable Diffusion](#stable-diffusion-v1) is a latent text-to-image diffusion
-model. Thanks to a generous compute donation from
-[Stability AI](https://stability.ai/) and support from
-[LAION](https://laion.ai/), we were able to train a Latent Diffusion Model on
-512x512 images from a subset of the [LAION-5B](https://laion.ai/blog/laion-5b/)
-database. Similar to Google's [Imagen](https://arxiv.org/abs/2205.11487), this
-model uses a frozen CLIP ViT-L/14 text encoder to condition the model on text
-prompts. With its 860M UNet and 123M text encoder, the model is relatively
-lightweight and runs on a GPU with at least 10GB VRAM. See
-[this section](#stable-diffusion-v1) below and the
-[model card](https://huggingface.co/CompVis/stable-diffusion).
-
-## Requirements
-
-A suitable [conda](https://conda.io/) environment named `ldm` can be created and
-activated with:
-
-```
-conda env create
-conda activate ldm
-```
-
-Note that the first line may be abbreviated `conda env create`, since conda will
-look for `environment.yml` by default.
-
-You can also update an existing
-[latent diffusion](https://github.com/CompVis/latent-diffusion) environment by
-running
-
-```bash
-conda install pytorch torchvision -c pytorch
-pip install transformers==4.19.2
-pip install -e .
-```
-
-## Stable Diffusion v1
-
-Stable Diffusion v1 refers to a specific configuration of the model architecture
-that uses a downsampling-factor 8 autoencoder with an 860M UNet and CLIP
-ViT-L/14 text encoder for the diffusion model. The model was pretrained on
-256x256 images and then finetuned on 512x512 images.
-
-\*Note: Stable Diffusion v1 is a general text-to-image diffusion model and
-therefore mirrors biases and (mis-)conceptions that are present in its training
-data. Details on the training procedure and data, as well as the intended use of
-the model can be found in the corresponding
-[model card](https://huggingface.co/CompVis/stable-diffusion). Research into the
-safe deployment of general text-to-image models is an ongoing effort. To prevent
-misuse and harm, we currently provide access to the checkpoints only for
-[academic research purposes upon request](https://stability.ai/academia-access-form).
-**This is an experiment in safe and community-driven publication of a capable
-and general text-to-image model. We are working on a public release with a more
-permissive license that also incorporates ethical considerations.\***
-
-[Request access to Stable Diffusion v1 checkpoints for academic research](https://stability.ai/academia-access-form)
-
-### Weights
-
-We currently provide three checkpoints, `sd-v1-1.ckpt`, `sd-v1-2.ckpt` and
-`sd-v1-3.ckpt`, which were trained as follows,
-
-- `sd-v1-1.ckpt`: 237k steps at resolution `256x256` on
- [laion2B-en](https://huggingface.co/datasets/laion/laion2B-en). 194k steps at
- resolution `512x512` on
- [laion-high-resolution](https://huggingface.co/datasets/laion/laion-high-resolution)
- (170M examples from LAION-5B with resolution `>= 1024x1024`).
-- `sd-v1-2.ckpt`: Resumed from `sd-v1-1.ckpt`. 515k steps at resolution
- `512x512` on "laion-improved-aesthetics" (a subset of laion2B-en, filtered to
- images with an original size `>= 512x512`, estimated aesthetics score `> 5.0`,
- and an estimated watermark probability `< 0.5`. The watermark estimate is from
- the LAION-5B metadata, the aesthetics score is estimated using an
- [improved aesthetics estimator](https://github.com/christophschuhmann/improved-aesthetic-predictor)).
-- `sd-v1-3.ckpt`: Resumed from `sd-v1-2.ckpt`. 195k steps at resolution
- `512x512` on "laion-improved-aesthetics" and 10\% dropping of the
- text-conditioning to improve
- [classifier-free guidance sampling](https://arxiv.org/abs/2207.12598).
-
-Evaluations with different classifier-free guidance scales (1.5, 2.0, 3.0, 4.0,
-5.0, 6.0, 7.0, 8.0) and 50 PLMS sampling steps show the relative improvements of
-the checkpoints: 
-
-### Text-to-Image with Stable Diffusion
-
-
-
-
-Stable Diffusion is a latent diffusion model conditioned on the (non-pooled)
-text embeddings of a CLIP ViT-L/14 text encoder.
-
-#### Sampling Script
-
-After [obtaining the weights](#weights), link them
-
-```
-mkdir -p models/ldm/stable-diffusion-v1/
-ln -s models/ldm/stable-diffusion-v1/model.ckpt
-```
-
-and sample with
-
-```
-python scripts/txt2img.py --prompt "a photograph of an astronaut riding a horse" --plms
-```
-
-By default, this uses a guidance scale of `--scale 7.5`,
-[Katherine Crowson's implementation](https://github.com/CompVis/latent-diffusion/pull/51)
-of the [PLMS](https://arxiv.org/abs/2202.09778) sampler, and renders images of
-size 512x512 (which it was trained on) in 50 steps. All supported arguments are
-listed below (type `python scripts/txt2img.py --help`).
-
-```commandline
-usage: txt2img.py [-h] [--prompt [PROMPT]] [--outdir [OUTDIR]] [--skip_grid] [--skip_save] [--ddim_steps DDIM_STEPS] [--plms] [--laion400m] [--fixed_code] [--ddim_eta DDIM_ETA] [--n_iter N_ITER] [--H H] [--W W] [--C C] [--f F] [--n_samples N_SAMPLES] [--n_rows N_ROWS]
- [--scale SCALE] [--from-file FROM_FILE] [--config CONFIG] [--ckpt CKPT] [--seed SEED] [--precision {full,autocast}]
-
-optional arguments:
- -h, --help show this help message and exit
- --prompt [PROMPT] the prompt to render
- --outdir [OUTDIR] dir to write results to
- --skip_grid do not save a grid, only individual samples. Helpful when evaluating lots of samples
- --skip_save do not save individual samples. For speed measurements.
- --ddim_steps DDIM_STEPS
- number of ddim sampling steps
- --plms use plms sampling
- --laion400m uses the LAION400M model
- --fixed_code if enabled, uses the same starting code across samples
- --ddim_eta DDIM_ETA ddim eta (eta=0.0 corresponds to deterministic sampling
- --n_iter N_ITER sample this often
- --H H image height, in pixel space
- --W W image width, in pixel space
- --C C latent channels
- --f F downsampling factor
- --n_samples N_SAMPLES
- how many samples to produce for each given prompt. A.k.a. batch size
- (note that the seeds for each image in the batch will be unavailable)
- --n_rows N_ROWS rows in the grid (default: n_samples)
- --scale SCALE unconditional guidance scale: eps = eps(x, empty) + scale * (eps(x, cond) - eps(x, empty))
- --from-file FROM_FILE
- if specified, load prompts from this file
- --config CONFIG path to config which constructs model
- --ckpt CKPT path to checkpoint of model
- --seed SEED the seed (for reproducible sampling)
- --precision {full,autocast}
- evaluate at this precision
-
-```
-
-Note: The inference config for all v1 versions is designed to be used with
-EMA-only checkpoints. For this reason `use_ema=False` is set in the
-configuration, otherwise the code will try to switch from non-EMA to EMA
-weights. If you want to examine the effect of EMA vs no EMA, we provide "full"
-checkpoints which contain both types of weights. For these, `use_ema=False` will
-load and use the non-EMA weights.
-
-#### Diffusers Integration
-
-Another way to download and sample Stable Diffusion is by using the
-[diffusers library](https://github.com/huggingface/diffusers/tree/main#new--stable-diffusion-is-now-fully-compatible-with-diffusers)
-
-```py
-# make sure you're logged in with `huggingface-cli login`
-from torch import autocast
-from diffusers import StableDiffusionPipeline, LMSDiscreteScheduler
-
-pipe = StableDiffusionPipeline.from_pretrained(
- "CompVis/stable-diffusion-v1-3-diffusers",
- use_auth_token=True
-)
-
-prompt = "a photo of an astronaut riding a horse on mars"
-with autocast("cuda"):
- image = pipe(prompt)["sample"][0]
-
-image.save("astronaut_rides_horse.png")
-```
-
-### Image Modification with Stable Diffusion
-
-By using a diffusion-denoising mechanism as first proposed by
-[SDEdit](https://arxiv.org/abs/2108.01073), the model can be used for different
-tasks such as text-guided image-to-image translation and upscaling. Similar to
-the txt2img sampling script, we provide a script to perform image modification
-with Stable Diffusion.
-
-The following describes an example where a rough sketch made in
-[Pinta](https://www.pinta-project.com/) is converted into a detailed artwork.
-
-```
-python scripts/img2img.py --prompt "A fantasy landscape, trending on artstation" --init-img --strength 0.8
-```
-
-Here, strength is a value between 0.0 and 1.0, that controls the amount of noise
-that is added to the input image. Values that approach 1.0 allow for lots of
-variations but will also produce images that are not semantically consistent
-with the input. See the following example.
-
-**Input**
-
-
-
-**Outputs**
-
-
-
-
-This procedure can, for example, also be used to upscale samples from the base
-model.
-
-## Comments
-
-- Our codebase for the diffusion models builds heavily on
- [OpenAI's ADM codebase](https://github.com/openai/guided-diffusion) and
- [https://github.com/lucidrains/denoising-diffusion-pytorch](https://github.com/lucidrains/denoising-diffusion-pytorch).
- Thanks for open-sourcing!
-
-- The implementation of the transformer encoder is from
- [x-transformers](https://github.com/lucidrains/x-transformers) by
- [lucidrains](https://github.com/lucidrains?tab=repositories).
-
-## BibTeX
-
-```
-@misc{rombach2021highresolution,
- title={High-Resolution Image Synthesis with Latent Diffusion Models},
- author={Robin Rombach and Andreas Blattmann and Dominik Lorenz and Patrick Esser and Björn Ommer},
- year={2021},
- eprint={2112.10752},
- archivePrefix={arXiv},
- primaryClass={cs.CV}
-}
-
-```
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 00000000000..29d0786e8ea
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "docs",
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "astro dev",
+ "start": "astro dev",
+ "generate-docs-data": "uv run python ../scripts/generate_docs_json.py",
+ "check-docs-data": "pnpm run generate-docs-data && git diff --exit-code -- src/generated",
+ "check-deploy-output": "node ./scripts/verify-deploy-output.mjs",
+ "check-redirects": "node ./scripts/validate-redirect-targets.mjs",
+ "build": "astro build",
+ "preview": "astro preview",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/starlight": "^0.39.2",
+ "@fontsource-variable/inter": "^5.2.8",
+ "@fontsource-variable/roboto-mono": "^5.2.9",
+ "astro": "^6.3.7",
+ "mermaid": "^11.15.0",
+ "rehype-external-links": "^3.0.0",
+ "sharp": "^0.34.5",
+ "starlight-changelogs": "^0.5.0",
+ "starlight-contextual-menu": "^0.1.5",
+ "starlight-links-validator": "^0.24.0",
+ "starlight-llms-txt": "^0.10.0"
+ },
+ "devDependencies": {
+ "node-addon-api": "^8.8.0",
+ "node-gyp": "^12.3.0"
+ },
+ "packageManager": "pnpm@10.12.4"
+}
diff --git a/docs/plugins/rehype-prefix-base-to-root-links.mjs b/docs/plugins/rehype-prefix-base-to-root-links.mjs
new file mode 100644
index 00000000000..fa424ab5c54
--- /dev/null
+++ b/docs/plugins/rehype-prefix-base-to-root-links.mjs
@@ -0,0 +1,53 @@
+export function rehypePrefixBaseToRootLinks(options = {}) {
+ const base = normalizeBase(options.base);
+
+ return (tree) => {
+ if (!base) {
+ return;
+ }
+
+ walk(tree, (node) => {
+ if (node.tagName !== 'a') {
+ return;
+ }
+
+ const href = node.properties?.href;
+
+ if (typeof href !== 'string') {
+ return;
+ }
+
+ if (!href.startsWith('/') || href.startsWith('//') || href.startsWith(`${base}/`)) {
+ return;
+ }
+
+ node.properties.href = `${base}${href}`;
+ });
+ };
+}
+
+function walk(node, visitor) {
+ if (!node || typeof node !== 'object') {
+ return;
+ }
+
+ if (node.type === 'element') {
+ visitor(node);
+ }
+
+ if (!Array.isArray(node.children)) {
+ return;
+ }
+
+ for (const child of node.children) {
+ walk(child, visitor);
+ }
+}
+
+function normalizeBase(base) {
+ if (!base || base === '/') {
+ return '';
+ }
+
+ return base.endsWith('/') ? base.slice(0, -1) : base;
+}
diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml
new file mode 100644
index 00000000000..a2daac1ce72
--- /dev/null
+++ b/docs/pnpm-lock.yaml
@@ -0,0 +1,5443 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@astrojs/starlight':
+ specifier: ^0.39.2
+ version: 0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@fontsource-variable/inter':
+ specifier: ^5.2.8
+ version: 5.2.8
+ '@fontsource-variable/roboto-mono':
+ specifier: ^5.2.9
+ version: 5.2.9
+ astro:
+ specifier: ^6.3.7
+ version: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ mermaid:
+ specifier: ^11.15.0
+ version: 11.15.0
+ rehype-external-links:
+ specifier: ^3.0.0
+ version: 3.0.0
+ sharp:
+ specifier: ^0.34.5
+ version: 0.34.5
+ starlight-changelogs:
+ specifier: ^0.5.0
+ version: 0.5.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ starlight-contextual-menu:
+ specifier: ^0.1.5
+ version: 0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))(starlight-markdown@0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))
+ starlight-links-validator:
+ specifier: ^0.24.0
+ version: 0.24.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ starlight-llms-txt:
+ specifier: ^0.10.0
+ version: 0.10.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ devDependencies:
+ node-addon-api:
+ specifier: ^8.8.0
+ version: 8.8.0
+ node-gyp:
+ specifier: ^12.3.0
+ version: 12.3.0
+
+packages:
+
+ '@antfu/install-pkg@1.1.0':
+ resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+
+ '@ascorbic/loader-utils@1.0.2':
+ resolution: {integrity: sha512-pg43g83gojVtEsAkXfjWuzJhuXneJp4wM/leBftGkCPV3yxKgB92EWA+nWu735BgbBMph3P7DrVqVc3ikt+dJA==}
+ peerDependencies:
+ astro: ^4.14.0 || ^5.0.0-beta.0
+
+ '@astrojs/compiler@4.0.0':
+ resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==}
+
+ '@astrojs/internal-helpers@0.9.0':
+ resolution: {integrity: sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg==}
+
+ '@astrojs/internal-helpers@0.9.1':
+ resolution: {integrity: sha512-1pWuARqYom/TzuU3+0ZugsTrKlUydWKuULmDqSMTuonY+9IRDUEGKX/8PXQ1nBxRq3w85uGtd9q9SXfqEldMIQ==}
+
+ '@astrojs/markdown-remark@7.1.1':
+ resolution: {integrity: sha512-C6e9BnLGlbdv6bV8MYGeHpHxsUHrCrB4OuRLqi5LI7oiBVcBcqfUN06zpwFQdHgV48QCCrMmLpyqBr7VqC+swA==}
+
+ '@astrojs/markdown-remark@7.1.2':
+ resolution: {integrity: sha512-caXZ4Dc2St2dW8luEg22GlP0gupLdztCTQE4EzZOxW1pqWXz9mbeJEuHUkgDYcKWW8tjIHkydYDhWLVoxJ327Q==}
+
+ '@astrojs/mdx@5.0.4':
+ resolution: {integrity: sha512-tSbuuYueNODiFAFaME7pjHY5lOLoxBYJi1cKd6scw9+a4ZO7C7UGdafEoVAQvOV2eO8a6RaHSAJYGVPL1w8BPA==}
+ engines: {node: '>=22.12.0'}
+ peerDependencies:
+ astro: ^6.0.0
+
+ '@astrojs/mdx@5.0.6':
+ resolution: {integrity: sha512-4dKe0ZMmqujofPNDHahzClkwinn9f8jHPcaXcgdGvPAlboD2mjzkUCofli2cBnxYAkdfhC6d50gBJ8i/cH8gHw==}
+ engines: {node: '>=22.12.0'}
+ peerDependencies:
+ astro: ^6.0.0
+
+ '@astrojs/prism@4.0.1':
+ resolution: {integrity: sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ==}
+ engines: {node: '>=22.12.0'}
+
+ '@astrojs/prism@4.0.2':
+ resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==}
+ engines: {node: '>=22.12.0'}
+
+ '@astrojs/sitemap@3.7.2':
+ resolution: {integrity: sha512-PqkzkcZTb5ICiyIR8VoKbIAP/laNRXi5tw616N1Ckk+40oNB8Can1AzVV56lrbC5GKSZFCyJYUVYqVivMisvpA==}
+
+ '@astrojs/starlight@0.39.2':
+ resolution: {integrity: sha512-vlw+bwnjtf5buCTUtLU7JfV6D3knslxqnspr6LKs6hfRuFZiyr5hT44F7GyDqR9FKANUqFxnIzWM81F1k/kOUA==}
+ peerDependencies:
+ astro: ^6.0.0
+
+ '@astrojs/telemetry@3.3.2':
+ resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==}
+ engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.3':
+ resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
+ '@braintree/sanitize-url@7.1.2':
+ resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+
+ '@capsizecss/unpack@4.0.0':
+ resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
+ engines: {node: '>=18'}
+
+ '@chevrotain/types@11.1.2':
+ resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
+
+ '@clack/core@1.3.1':
+ resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==}
+ engines: {node: '>= 20.12.0'}
+
+ '@clack/prompts@1.4.0':
+ resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==}
+ engines: {node: '>= 20.12.0'}
+
+ '@ctrl/tinycolor@4.2.0':
+ resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
+ engines: {node: '>=14'}
+
+ '@emnapi/runtime@1.9.2':
+ resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
+
+ '@esbuild/aix-ppc64@0.27.7':
+ resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.27.7':
+ resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.27.7':
+ resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.27.7':
+ resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.27.7':
+ resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.27.7':
+ resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.27.7':
+ resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.27.7':
+ resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.27.7':
+ resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.27.7':
+ resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.27.7':
+ resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.27.7':
+ resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.27.7':
+ resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.27.7':
+ resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.27.7':
+ resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.27.7':
+ resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.27.7':
+ resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.27.7':
+ resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.27.7':
+ resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.27.7':
+ resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.27.7':
+ resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@expressive-code/core@0.42.0':
+ resolution: {integrity: sha512-MN11+9nfmaC7sYu2BZJXAXqwkBRt8t1xTSqP+Ti1NfTEskgl6xUnzDxoaiQkg0BMzpglA0pys4dpDKquP/cyIw==}
+
+ '@expressive-code/plugin-frames@0.42.0':
+ resolution: {integrity: sha512-XtkPm+941Uta7Y+81Acv+OA/20F1NJmJhCX6UYGKpqEIGqplNh3PTOhcURp6tcruhlzJcWcvpWy6Oigz3SrjqA==}
+
+ '@expressive-code/plugin-shiki@0.42.0':
+ resolution: {integrity: sha512-PMKey/kLmewttAHQezL+Y5Fx3vVssfDi3+FJOYQQS2mXP3tQspFELtKKAfsXfmSXdToZYgwoO69HJndqfE+09g==}
+
+ '@expressive-code/plugin-text-markers@0.42.0':
+ resolution: {integrity: sha512-l59lUx8fq1v5g6SpmbDjiU0+7IdfbiWnAyRmtTVSpfhyq+nZMN4UcmYyu2b9Mynhzt7Gr+O+cXyEPDNb2AVWVQ==}
+
+ '@fontsource-variable/inter@5.2.8':
+ resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
+
+ '@fontsource-variable/roboto-mono@5.2.9':
+ resolution: {integrity: sha512-OzFO2AXlSGcXl/NcXS3CGjImb6rczCByPJ1C+Dzp9kkYOrUPyrGTuAtqPcmA/d+nZGX5oyOWKXLk5BrwVLYqkw==}
+
+ '@iconify/types@2.0.0':
+ resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+ '@iconify/utils@3.1.0':
+ resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@mdx-js/mdx@3.1.1':
+ resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==}
+
+ '@mermaid-js/parser@1.1.1':
+ resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
+
+ '@oslojs/encoding@1.1.0':
+ resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
+
+ '@pagefind/darwin-arm64@1.5.2':
+ resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pagefind/darwin-x64@1.5.2':
+ resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pagefind/default-ui@1.5.2':
+ resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==}
+
+ '@pagefind/freebsd-x64@1.5.2':
+ resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@pagefind/linux-arm64@1.5.2':
+ resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@pagefind/linux-x64@1.5.2':
+ resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@pagefind/windows-arm64@1.5.2':
+ resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pagefind/windows-x64@1.5.2':
+ resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/pluginutils@5.3.0':
+ resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@shikijs/core@4.0.2':
+ resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==}
+ engines: {node: '>=20'}
+
+ '@shikijs/core@4.1.0':
+ resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==}
+ engines: {node: '>=20'}
+
+ '@shikijs/engine-javascript@4.0.2':
+ resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==}
+ engines: {node: '>=20'}
+
+ '@shikijs/engine-javascript@4.1.0':
+ resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==}
+ engines: {node: '>=20'}
+
+ '@shikijs/engine-oniguruma@4.0.2':
+ resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==}
+ engines: {node: '>=20'}
+
+ '@shikijs/engine-oniguruma@4.1.0':
+ resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==}
+ engines: {node: '>=20'}
+
+ '@shikijs/langs@4.0.2':
+ resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==}
+ engines: {node: '>=20'}
+
+ '@shikijs/langs@4.1.0':
+ resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==}
+ engines: {node: '>=20'}
+
+ '@shikijs/primitive@4.0.2':
+ resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
+ engines: {node: '>=20'}
+
+ '@shikijs/primitive@4.1.0':
+ resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==}
+ engines: {node: '>=20'}
+
+ '@shikijs/themes@4.0.2':
+ resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
+ engines: {node: '>=20'}
+
+ '@shikijs/themes@4.1.0':
+ resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==}
+ engines: {node: '>=20'}
+
+ '@shikijs/types@4.0.2':
+ resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==}
+ engines: {node: '>=20'}
+
+ '@shikijs/types@4.1.0':
+ resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==}
+ engines: {node: '>=20'}
+
+ '@shikijs/vscode-textmate@10.0.2':
+ resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
+ '@types/braces@3.0.5':
+ resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==}
+
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-axis@3.0.6':
+ resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==}
+
+ '@types/d3-brush@3.0.6':
+ resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==}
+
+ '@types/d3-chord@3.0.6':
+ resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-contour@3.0.6':
+ resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==}
+
+ '@types/d3-delaunay@6.0.4':
+ resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==}
+
+ '@types/d3-dispatch@3.0.7':
+ resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==}
+
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
+ '@types/d3-dsv@3.0.7':
+ resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-fetch@3.0.7':
+ resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==}
+
+ '@types/d3-force@3.0.10':
+ resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
+
+ '@types/d3-format@3.0.4':
+ resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==}
+
+ '@types/d3-geo@3.1.0':
+ resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==}
+
+ '@types/d3-hierarchy@3.1.7':
+ resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-polygon@3.0.2':
+ resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==}
+
+ '@types/d3-quadtree@3.0.6':
+ resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==}
+
+ '@types/d3-random@3.0.3':
+ resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==}
+
+ '@types/d3-scale-chromatic@3.1.0':
+ resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time-format@4.0.3':
+ resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
+ '@types/d3@7.4.3':
+ resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==}
+
+ '@types/debug@4.1.13':
+ resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
+ '@types/hast@3.0.4':
+ resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
+ '@types/js-yaml@4.0.9':
+ resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
+
+ '@types/mdast@4.0.4':
+ resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
+ '@types/mdx@2.0.13':
+ resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
+
+ '@types/micromatch@4.0.10':
+ resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
+ '@types/nlcst@2.0.3':
+ resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==}
+
+ '@types/node@24.12.3':
+ resolution: {integrity: sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==}
+
+ '@types/picomatch@4.0.3':
+ resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==}
+
+ '@types/sax@1.2.7':
+ resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
+
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
+ '@types/unist@2.0.11':
+ resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+ '@types/unist@3.0.3':
+ resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
+ '@ungap/structured-clone@1.3.0':
+ resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+
+ '@ungap/structured-clone@1.3.1':
+ resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
+
+ '@upsetjs/venn.js@2.0.0':
+ resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==}
+
+ abbrev@4.0.0:
+ resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.16.0:
+ resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ ansi-escapes@7.3.0:
+ resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
+ engines: {node: '>=18'}
+
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ arg@5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ array-iterate@2.0.1:
+ resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==}
+
+ astring@1.9.0:
+ resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
+ hasBin: true
+
+ astro-expressive-code@0.42.0:
+ resolution: {integrity: sha512-aiTePi2Cn0mJPYWZSzP1GcxCinX9mNtJyCCshVVPSg1yRwM7ADvFJOx0FnS440M9t65hp8JH//dc2qr22Bm4ag==}
+ peerDependencies:
+ astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta
+
+ astro@6.3.7:
+ resolution: {integrity: sha512-zIeDRrI0qNgN1lcCjNqt6/IVCVej7VwSa326cO8uP9BOk1cg4QuffhLnOn2gCgWQr32/wxpSRFfXiLKHglu1Tw==}
+ engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'}
+ hasBin: true
+
+ axobject-query@4.1.0:
+ resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
+ engines: {node: '>= 0.4'}
+
+ bail@2.0.2:
+ resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
+ bcp-47-match@2.0.3:
+ resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==}
+
+ bcp-47@2.1.0:
+ resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
+
+ boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+
+ braces@3.0.3:
+ resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
+ engines: {node: '>=8'}
+
+ ccount@2.0.1:
+ resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
+ character-entities-html4@2.1.0:
+ resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+ character-entities-legacy@3.0.0:
+ resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+ character-entities@2.0.2:
+ resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+ character-reference-invalid@2.0.1:
+ resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
+ chokidar@5.0.0:
+ resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
+ engines: {node: '>= 20.19.0'}
+
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
+ ci-info@4.4.0:
+ resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
+ engines: {node: '>=8'}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ collapse-white-space@2.1.0:
+ resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
+
+ comma-separated-tokens@2.0.3:
+ resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
+ commander@11.1.0:
+ resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
+ engines: {node: '>=16'}
+
+ commander@7.2.0:
+ resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+ engines: {node: '>= 10'}
+
+ commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+
+ common-ancestor-path@2.0.0:
+ resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==}
+ engines: {node: '>= 18'}
+
+ confbox@0.1.8:
+ resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+
+ cookie-es@1.2.3:
+ resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cose-base@1.0.3:
+ resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
+
+ cose-base@2.2.0:
+ resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
+
+ crossws@0.3.5:
+ resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
+
+ css-select@5.2.2:
+ resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+
+ css-selector-parser@3.3.0:
+ resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==}
+
+ css-tree@2.2.1:
+ resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css-what@6.2.2:
+ resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
+ engines: {node: '>= 6'}
+
+ cssesc@3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
+ csso@5.0.5:
+ resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
+ cytoscape-cose-bilkent@4.1.0:
+ resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
+ peerDependencies:
+ cytoscape: ^3.2.0
+
+ cytoscape-fcose@2.2.0:
+ resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==}
+ peerDependencies:
+ cytoscape: ^3.2.0
+
+ cytoscape@3.33.2:
+ resolution: {integrity: sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==}
+ engines: {node: '>=0.10'}
+
+ d3-array@2.12.1:
+ resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==}
+
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-axis@3.0.0:
+ resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
+ engines: {node: '>=12'}
+
+ d3-brush@3.0.0:
+ resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
+ engines: {node: '>=12'}
+
+ d3-chord@3.0.1:
+ resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-contour@4.0.2:
+ resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
+ engines: {node: '>=12'}
+
+ d3-delaunay@6.0.4:
+ resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
+ engines: {node: '>=12'}
+
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
+ d3-dsv@3.0.1:
+ resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-fetch@3.0.1:
+ resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
+ engines: {node: '>=12'}
+
+ d3-force@3.0.0:
+ resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-geo@3.1.1:
+ resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
+ engines: {node: '>=12'}
+
+ d3-hierarchy@3.1.2:
+ resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@1.0.9:
+ resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-polygon@3.0.1:
+ resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
+ engines: {node: '>=12'}
+
+ d3-quadtree@3.0.1:
+ resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
+ engines: {node: '>=12'}
+
+ d3-random@3.0.1:
+ resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
+ engines: {node: '>=12'}
+
+ d3-sankey@0.12.3:
+ resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==}
+
+ d3-scale-chromatic@3.1.0:
+ resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@1.3.7:
+ resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
+ d3@7.9.0:
+ resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
+ engines: {node: '>=12'}
+
+ dagre-d3-es@7.0.14:
+ resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==}
+
+ dayjs@1.11.20:
+ resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decode-named-character-reference@1.3.0:
+ resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
+ delaunator@5.1.0:
+ resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ devalue@5.8.1:
+ resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
+
+ devlop@1.1.0:
+ resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+ diff@8.0.4:
+ resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
+ engines: {node: '>=0.3.1'}
+
+ direction@2.0.1:
+ resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==}
+ hasBin: true
+
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ dompurify@3.3.3:
+ resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
+
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+ dset@3.1.4:
+ resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
+ engines: {node: '>=4'}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
+ environment@1.1.0:
+ resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
+ engines: {node: '>=18'}
+
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
+
+ es-toolkit@1.46.1:
+ resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
+
+ esast-util-from-estree@2.0.0:
+ resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
+
+ esast-util-from-js@2.0.1:
+ resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==}
+
+ esbuild@0.27.7:
+ resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escape-string-regexp@5.0.0:
+ resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
+ engines: {node: '>=12'}
+
+ estree-util-attach-comments@3.0.0:
+ resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==}
+
+ estree-util-build-jsx@3.0.1:
+ resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==}
+
+ estree-util-is-identifier-name@3.0.0:
+ resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
+ estree-util-scope@1.0.0:
+ resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==}
+
+ estree-util-to-js@2.0.0:
+ resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==}
+
+ estree-util-visit@2.0.0:
+ resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==}
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
+ exponential-backoff@3.1.3:
+ resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
+
+ expressive-code@0.42.0:
+ resolution: {integrity: sha512-V5DtJLEKuj4wf9O6IRtPtRObkMVy2ggR+S0MdjrTw6m58krZnDioyhW1si3Y04c5YPeooP4nd85Yq9NwEVHS4g==}
+
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ fast-string-truncated-width@3.0.3:
+ resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
+
+ fast-string-width@3.0.2:
+ resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
+
+ fast-wrap-ansi@0.2.2:
+ resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fill-range@7.1.1:
+ resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
+ engines: {node: '>=8'}
+
+ flattie@1.1.1:
+ resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
+ engines: {node: '>=8'}
+
+ fontace@0.4.1:
+ resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==}
+
+ fontkitten@1.0.3:
+ resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==}
+ engines: {node: '>=20'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ get-tsconfig@5.0.0-beta.4:
+ resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==}
+ engines: {node: '>=20.20.0'}
+
+ github-slugger@2.0.0:
+ resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ h3@1.15.11:
+ resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
+
+ hachure-fill@0.5.2:
+ resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+
+ has-flag@5.0.1:
+ resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
+ engines: {node: '>=12'}
+
+ hast-util-embedded@3.0.0:
+ resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==}
+
+ hast-util-format@1.1.0:
+ resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==}
+
+ hast-util-from-html@2.0.3:
+ resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
+
+ hast-util-from-parse5@8.0.3:
+ resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+
+ hast-util-has-property@3.0.0:
+ resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==}
+
+ hast-util-is-body-ok-link@3.0.1:
+ resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==}
+
+ hast-util-is-element@3.0.0:
+ resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
+
+ hast-util-minify-whitespace@1.0.1:
+ resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==}
+
+ hast-util-parse-selector@4.0.0:
+ resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
+
+ hast-util-phrasing@3.0.1:
+ resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==}
+
+ hast-util-raw@9.1.0:
+ resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+
+ hast-util-select@6.0.4:
+ resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==}
+
+ hast-util-to-estree@3.1.3:
+ resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
+
+ hast-util-to-html@9.0.5:
+ resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
+ hast-util-to-jsx-runtime@2.3.6:
+ resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+ hast-util-to-mdast@10.1.2:
+ resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==}
+
+ hast-util-to-parse5@8.0.1:
+ resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+
+ hast-util-to-string@3.0.1:
+ resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
+
+ hast-util-to-text@4.0.2:
+ resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
+
+ hast-util-whitespace@3.0.0:
+ resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+ hastscript@9.0.1:
+ resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+
+ html-escaper@3.0.3:
+ resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
+
+ html-void-elements@3.0.0:
+ resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
+ html-whitespace-sensitive-tag-names@3.0.1:
+ resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==}
+
+ http-cache-semantics@4.2.0:
+ resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+
+ i18next@26.1.0:
+ resolution: {integrity: sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
+ inline-style-parser@0.2.7:
+ resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
+ internmap@1.0.1:
+ resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==}
+
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
+ iron-webcrypto@1.2.1:
+ resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
+
+ is-absolute-url@4.0.1:
+ resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
+ is-absolute-url@5.0.0:
+ resolution: {integrity: sha512-sdJyNpBnQHuVnBunfzjAecOhZr2+A30ywfFvu3EnxtKLUWfwGgyWUmqHbGZiU6vTfHpCPm5GvLe4BAvlU9n8VQ==}
+ engines: {node: '>=20'}
+
+ is-alphabetical@2.0.1:
+ resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+ is-alphanumerical@2.0.1:
+ resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
+ is-decimal@2.0.1:
+ resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
+ is-docker@4.0.0:
+ resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ is-hexadecimal@2.0.1:
+ resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
+ is-number@7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+
+ is-plain-obj@4.1.0:
+ resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
+ engines: {node: '>=12'}
+
+ is-wsl@3.1.1:
+ resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
+ engines: {node: '>=16'}
+
+ isexe@4.0.0:
+ resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
+ engines: {node: '>=20'}
+
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
+ hasBin: true
+
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
+ katex@0.16.45:
+ resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==}
+ hasBin: true
+
+ khroma@2.1.0:
+ resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+
+ klona@2.0.6:
+ resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
+ engines: {node: '>= 8'}
+
+ layout-base@1.0.2:
+ resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
+
+ layout-base@2.0.1:
+ resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
+ longest-streak@3.1.0:
+ resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
+ lru-cache@11.5.0:
+ resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==}
+ engines: {node: 20 || >=22}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ magicast@0.5.3:
+ resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
+
+ markdown-extensions@2.0.0:
+ resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
+ engines: {node: '>=16'}
+
+ markdown-table@3.0.4:
+ resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
+ marked@16.4.2:
+ resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
+ mdast-util-definitions@6.0.0:
+ resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}
+
+ mdast-util-directive@3.1.0:
+ resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
+
+ mdast-util-find-and-replace@3.0.2:
+ resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+ mdast-util-from-markdown@2.0.3:
+ resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+ mdast-util-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+ mdast-util-gfm-table@2.0.0:
+ resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+ mdast-util-gfm@3.1.0:
+ resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+ mdast-util-mdx-expression@2.0.1:
+ resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+ mdast-util-mdx-jsx@3.2.0:
+ resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+ mdast-util-mdx@3.0.0:
+ resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==}
+
+ mdast-util-mdxjs-esm@2.0.1:
+ resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+ mdast-util-phrasing@4.1.0:
+ resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+ mdast-util-to-hast@13.2.1:
+ resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+ mdast-util-to-markdown@2.1.2:
+ resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+ mdast-util-to-string@4.0.0:
+ resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
+ mdn-data@2.0.28:
+ resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
+
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
+ mermaid@11.15.0:
+ resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
+
+ micromark-core-commonmark@2.0.3:
+ resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+ micromark-extension-directive@4.0.0:
+ resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+ micromark-extension-gfm-footnote@2.1.0:
+ resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+ micromark-extension-gfm-table@2.1.1:
+ resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+ micromark-extension-gfm@3.0.0:
+ resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+ micromark-extension-mdx-expression@3.0.1:
+ resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==}
+
+ micromark-extension-mdx-jsx@3.0.2:
+ resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==}
+
+ micromark-extension-mdx-md@2.0.0:
+ resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==}
+
+ micromark-extension-mdxjs-esm@3.0.0:
+ resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==}
+
+ micromark-extension-mdxjs@3.0.0:
+ resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==}
+
+ micromark-factory-destination@2.0.1:
+ resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+ micromark-factory-label@2.0.1:
+ resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+ micromark-factory-mdx-expression@2.0.3:
+ resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==}
+
+ micromark-factory-space@2.0.1:
+ resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+ micromark-factory-title@2.0.1:
+ resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+ micromark-factory-whitespace@2.0.1:
+ resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+ micromark-util-character@2.1.1:
+ resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+ micromark-util-chunked@2.0.1:
+ resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+ micromark-util-classify-character@2.0.1:
+ resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+ micromark-util-combine-extensions@2.0.1:
+ resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+ micromark-util-decode-string@2.0.1:
+ resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+ micromark-util-encode@2.0.1:
+ resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+ micromark-util-events-to-acorn@2.0.3:
+ resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==}
+
+ micromark-util-html-tag-name@2.0.1:
+ resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+ micromark-util-normalize-identifier@2.0.1:
+ resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+ micromark-util-resolve-all@2.0.1:
+ resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+ micromark-util-sanitize-uri@2.0.1:
+ resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+ micromark-util-subtokenize@2.1.0:
+ resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+ micromark-util-symbol@2.0.1:
+ resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+ micromark-util-types@2.0.2:
+ resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+ micromark@4.0.2:
+ resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minizlib@3.1.0:
+ resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+ engines: {node: '>= 18'}
+
+ mlly@1.8.2:
+ resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
+
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ neotraverse@0.6.18:
+ resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
+ engines: {node: '>= 10'}
+
+ nlcst-to-string@4.0.0:
+ resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
+
+ node-addon-api@8.8.0:
+ resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==}
+ engines: {node: ^18 || ^20 || >= 21}
+
+ node-fetch-native@1.6.7:
+ resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+
+ node-gyp@12.3.0:
+ resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
+ node-mock-http@1.0.4:
+ resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
+
+ nopt@9.0.0:
+ resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
+ nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
+ ofetch@1.5.1:
+ resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
+
+ ohash@2.0.11:
+ resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
+
+ oniguruma-parser@0.12.1:
+ resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
+
+ oniguruma-parser@0.12.2:
+ resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
+ oniguruma-to-es@4.3.5:
+ resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
+
+ oniguruma-to-es@4.3.6:
+ resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
+ p-limit@7.3.0:
+ resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
+ engines: {node: '>=20'}
+
+ p-queue@9.3.0:
+ resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==}
+ engines: {node: '>=20'}
+
+ p-timeout@7.0.1:
+ resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
+ engines: {node: '>=20'}
+
+ package-manager-detector@1.6.0:
+ resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
+
+ pagefind@1.5.2:
+ resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==}
+ hasBin: true
+
+ parse-entities@4.0.2:
+ resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
+ parse-latin@7.0.0:
+ resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==}
+
+ parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
+ path-data-parser@0.1.0:
+ resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ piccolore@0.1.3:
+ resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ pkg-types@1.3.1:
+ resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+
+ points-on-curve@0.2.0:
+ resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
+
+ points-on-path@0.2.1:
+ resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
+
+ postcss-nested@6.2.0:
+ resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+
+ postcss-selector-parser@6.1.2:
+ resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
+ engines: {node: '>=4'}
+
+ postcss@8.5.14:
+ resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prismjs@1.30.0:
+ resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
+ engines: {node: '>=6'}
+
+ proc-log@6.1.0:
+ resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
+ property-information@7.1.0:
+ resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
+ radix3@1.1.2:
+ resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
+
+ readdirp@5.0.0:
+ resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
+ engines: {node: '>= 20.19.0'}
+
+ recma-build-jsx@1.0.0:
+ resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
+
+ recma-jsx@1.0.1:
+ resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ recma-parse@1.0.0:
+ resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==}
+
+ recma-stringify@1.0.0:
+ resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
+
+ regex-recursion@6.0.2:
+ resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+ regex-utilities@2.3.0:
+ resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+ regex@6.1.0:
+ resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
+ rehype-expressive-code@0.42.0:
+ resolution: {integrity: sha512-8rp/1YMEVVSYbtz+bFBx+uSx3vA4i4T8RwRm5Q/IWbucQnnQqQ0hDqtmKOr8tv+59Cik6cu5aH3WPo0I7csuTA==}
+
+ rehype-external-links@3.0.0:
+ resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==}
+
+ rehype-format@5.0.1:
+ resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
+
+ rehype-minify-whitespace@6.0.2:
+ resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==}
+
+ rehype-parse@9.0.1:
+ resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==}
+
+ rehype-raw@7.0.0:
+ resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+
+ rehype-recma@1.0.0:
+ resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
+
+ rehype-remark@10.0.1:
+ resolution: {integrity: sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ==}
+
+ rehype-stringify@10.0.1:
+ resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==}
+
+ rehype@13.0.2:
+ resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==}
+
+ remark-directive@4.0.0:
+ resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==}
+
+ remark-gfm@4.0.1:
+ resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+ remark-mdx@3.1.1:
+ resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==}
+
+ remark-parse@11.0.0:
+ resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+ remark-rehype@11.1.2:
+ resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+ remark-smartypants@3.0.2:
+ resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==}
+ engines: {node: '>=16.0.0'}
+
+ remark-stringify@11.0.0:
+ resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
+ resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+ retext-latin@4.0.0:
+ resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==}
+
+ retext-smartypants@6.2.0:
+ resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==}
+
+ retext-stringify@4.0.0:
+ resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==}
+
+ retext@9.0.0:
+ resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==}
+
+ robust-predicates@3.0.3:
+ resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==}
+
+ rollup@4.60.4:
+ resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ roughjs@4.6.6:
+ resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+
+ rw@1.3.3:
+ resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ sax@1.6.0:
+ resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ engines: {node: '>=11.0.0'}
+
+ semver@7.7.4:
+ resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ semver@7.8.1:
+ resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ shiki@4.0.2:
+ resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==}
+ engines: {node: '>=20'}
+
+ shiki@4.1.0:
+ resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==}
+ engines: {node: '>=20'}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ sitemap@9.0.1:
+ resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==}
+ engines: {node: '>=20.19.5', npm: '>=10.8.2'}
+ hasBin: true
+
+ smol-toml@1.6.1:
+ resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
+ engines: {node: '>= 18'}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ source-map@0.7.6:
+ resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+ engines: {node: '>= 12'}
+
+ space-separated-tokens@2.0.2:
+ resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
+ starlight-changelogs@0.5.0:
+ resolution: {integrity: sha512-bqBAHle6N4jApkLz8qlN+k8nmaJUXvYunrJeYWTZats15SeEmsT+1N7KdshODCky2AzLfIBy4Qa2v1YSQNrjxQ==}
+ engines: {node: '>=22.12.0'}
+ peerDependencies:
+ '@astrojs/starlight': '>=0.38.0'
+
+ starlight-contextual-menu@0.1.5:
+ resolution: {integrity: sha512-MYQ6eFDIBBnKrEh3XqR7RZ6YDJ641ADmrSjj93d+cVJGPvrCHrd6VYiKeehhczsrn6GqjaCCFAn4xUd69gcfcQ==}
+ peerDependencies:
+ astro: ^5.0.0
+ starlight-markdown: ^0.1.5
+
+ starlight-links-validator@0.24.0:
+ resolution: {integrity: sha512-bsZf77oRJmY92KWOcu3vYK8Y12KJNvO3jQca1BgOBs+XskNfjPXrkgVtT7ls/FnLoomfsIV0wLdJfJs7kzGojA==}
+ engines: {node: '>=22.12.0'}
+ peerDependencies:
+ '@astrojs/starlight': '>=0.38.0'
+ astro: '>=6.0.0'
+
+ starlight-llms-txt@0.10.0:
+ resolution: {integrity: sha512-LgkSjkvdACsGHkFq1ES00F0BU4lRepjJoaUmOgxBxNWx4txwpySVPtntKdAvDvlhinyN0ZBRpnAsN/sVQ1UEfA==}
+ peerDependencies:
+ '@astrojs/starlight': '>=0.38.0'
+ astro: ^6.0.0
+
+ starlight-markdown@0.1.5:
+ resolution: {integrity: sha512-23LXRaZp7pyE+r/HP6rxHfwic8HfvUBT4EImECA6encs/eTtrF0Z+7svANofdtfbiNt31D5q26i03B6FtcSmGg==}
+ peerDependencies:
+ astro: ^5.0.0
+
+ stream-replace-string@2.0.0:
+ resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==}
+
+ stringify-entities@4.0.4:
+ resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
+ style-to-js@1.1.21:
+ resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+ style-to-object@1.0.14:
+ resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
+ stylis@4.3.6:
+ resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
+
+ supports-color@10.2.2:
+ resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
+ engines: {node: '>=18'}
+
+ supports-hyperlinks@4.4.0:
+ resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==}
+ engines: {node: '>=20'}
+
+ svgo@4.0.1:
+ resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ tar@7.5.15:
+ resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
+ engines: {node: '>=18'}
+
+ terminal-link@5.0.0:
+ resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==}
+ engines: {node: '>=20'}
+
+ tiny-inflate@1.0.3:
+ resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+
+ tinyclip@0.1.12:
+ resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==}
+ engines: {node: ^16.14.0 || >= 17.3.0}
+
+ tinyexec@1.0.4:
+ resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
+ engines: {node: '>=18'}
+
+ tinyexec@1.2.2:
+ resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ to-regex-range@5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+
+ trim-lines@3.0.1:
+ resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
+ trim-trailing-lines@2.1.0:
+ resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==}
+
+ trough@2.2.0:
+ resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
+ ts-dedent@2.2.0:
+ resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
+ engines: {node: '>=6.10'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ ufo@1.6.3:
+ resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
+
+ ufo@1.6.4:
+ resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
+
+ ultrahtml@1.6.0:
+ resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==}
+
+ uncrypto@0.1.3:
+ resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ undici@6.25.0:
+ resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
+ engines: {node: '>=18.17'}
+
+ unified@11.0.5:
+ resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+ unifont@0.7.4:
+ resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==}
+
+ unist-util-find-after@5.0.0:
+ resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
+
+ unist-util-is@6.0.1:
+ resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+ unist-util-modify-children@4.0.0:
+ resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==}
+
+ unist-util-position-from-estree@2.0.0:
+ resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==}
+
+ unist-util-position@5.0.0:
+ resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+ unist-util-remove-position@5.0.0:
+ resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==}
+
+ unist-util-remove@4.0.0:
+ resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==}
+
+ unist-util-stringify-position@4.0.0:
+ resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+ unist-util-visit-children@3.0.0:
+ resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==}
+
+ unist-util-visit-parents@6.0.2:
+ resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+ unist-util-visit@5.1.0:
+ resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
+ unstorage@1.17.5:
+ resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==}
+ peerDependencies:
+ '@azure/app-configuration': ^1.8.0
+ '@azure/cosmos': ^4.2.0
+ '@azure/data-tables': ^13.3.0
+ '@azure/identity': ^4.6.0
+ '@azure/keyvault-secrets': ^4.9.0
+ '@azure/storage-blob': ^12.26.0
+ '@capacitor/preferences': ^6 || ^7 || ^8
+ '@deno/kv': '>=0.9.0'
+ '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0
+ '@planetscale/database': ^1.19.0
+ '@upstash/redis': ^1.34.3
+ '@vercel/blob': '>=0.27.1'
+ '@vercel/functions': ^2.2.12 || ^3.0.0
+ '@vercel/kv': ^1 || ^2 || ^3
+ aws4fetch: ^1.0.20
+ db0: '>=0.2.1'
+ idb-keyval: ^6.2.1
+ ioredis: ^5.4.2
+ uploadthing: ^7.4.4
+ peerDependenciesMeta:
+ '@azure/app-configuration':
+ optional: true
+ '@azure/cosmos':
+ optional: true
+ '@azure/data-tables':
+ optional: true
+ '@azure/identity':
+ optional: true
+ '@azure/keyvault-secrets':
+ optional: true
+ '@azure/storage-blob':
+ optional: true
+ '@capacitor/preferences':
+ optional: true
+ '@deno/kv':
+ optional: true
+ '@netlify/blobs':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/blob':
+ optional: true
+ '@vercel/functions':
+ optional: true
+ '@vercel/kv':
+ optional: true
+ aws4fetch:
+ optional: true
+ db0:
+ optional: true
+ idb-keyval:
+ optional: true
+ ioredis:
+ optional: true
+ uploadthing:
+ optional: true
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ uuid@11.1.0:
+ resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ hasBin: true
+
+ vfile-location@5.0.3:
+ resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
+
+ vfile-message@4.0.3:
+ resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+ vfile@6.0.3:
+ resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
+ vite@7.3.3:
+ resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitefu@1.1.3:
+ resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
+ web-namespaces@2.0.1:
+ resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+
+ which-pm-runs@1.1.0:
+ resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
+ engines: {node: '>=4'}
+
+ which@6.0.1:
+ resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
+ xxhash-wasm@1.1.0:
+ resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
+
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
+ yargs-parser@22.0.0:
+ resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
+ yocto-queue@1.2.2:
+ resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
+ engines: {node: '>=12.20'}
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+ zwitch@2.0.4:
+ resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
+snapshots:
+
+ '@antfu/install-pkg@1.1.0':
+ dependencies:
+ package-manager-detector: 1.6.0
+ tinyexec: 1.0.4
+
+ '@ascorbic/loader-utils@1.0.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))':
+ dependencies:
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+
+ '@astrojs/compiler@4.0.0': {}
+
+ '@astrojs/internal-helpers@0.9.0':
+ dependencies:
+ picomatch: 4.0.4
+
+ '@astrojs/internal-helpers@0.9.1':
+ dependencies:
+ picomatch: 4.0.4
+
+ '@astrojs/markdown-remark@7.1.1':
+ dependencies:
+ '@astrojs/internal-helpers': 0.9.0
+ '@astrojs/prism': 4.0.1
+ github-slugger: 2.0.0
+ hast-util-from-html: 2.0.3
+ hast-util-to-text: 4.0.2
+ js-yaml: 4.1.1
+ mdast-util-definitions: 6.0.0
+ rehype-raw: 7.0.0
+ rehype-stringify: 10.0.1
+ remark-gfm: 4.0.1
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ remark-smartypants: 3.0.2
+ retext-smartypants: 6.2.0
+ shiki: 4.0.2
+ smol-toml: 1.6.1
+ unified: 11.0.5
+ unist-util-remove-position: 5.0.0
+ unist-util-visit: 5.1.0
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@astrojs/markdown-remark@7.1.2':
+ dependencies:
+ '@astrojs/internal-helpers': 0.9.1
+ '@astrojs/prism': 4.0.2
+ github-slugger: 2.0.0
+ hast-util-from-html: 2.0.3
+ hast-util-to-text: 4.0.2
+ js-yaml: 4.1.1
+ mdast-util-definitions: 6.0.0
+ rehype-raw: 7.0.0
+ rehype-stringify: 10.0.1
+ remark-gfm: 4.0.1
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ remark-smartypants: 3.0.2
+ retext-smartypants: 6.2.0
+ shiki: 4.1.0
+ smol-toml: 1.6.1
+ unified: 11.0.5
+ unist-util-remove-position: 5.0.0
+ unist-util-visit: 5.1.0
+ unist-util-visit-parents: 6.0.2
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@astrojs/mdx@5.0.4(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))':
+ dependencies:
+ '@astrojs/markdown-remark': 7.1.1
+ '@mdx-js/mdx': 3.1.1
+ acorn: 8.16.0
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ es-module-lexer: 2.1.0
+ estree-util-visit: 2.0.0
+ hast-util-to-html: 9.0.5
+ piccolore: 0.1.3
+ rehype-raw: 7.0.0
+ remark-gfm: 4.0.1
+ remark-smartypants: 3.0.2
+ source-map: 0.7.6
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@astrojs/mdx@5.0.6(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))':
+ dependencies:
+ '@astrojs/markdown-remark': 7.1.2
+ '@mdx-js/mdx': 3.1.1
+ acorn: 8.16.0
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ es-module-lexer: 2.1.0
+ estree-util-visit: 2.0.0
+ hast-util-to-html: 9.0.5
+ piccolore: 0.1.3
+ rehype-raw: 7.0.0
+ remark-gfm: 4.0.1
+ remark-smartypants: 3.0.2
+ source-map: 0.7.6
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@astrojs/prism@4.0.1':
+ dependencies:
+ prismjs: 1.30.0
+
+ '@astrojs/prism@4.0.2':
+ dependencies:
+ prismjs: 1.30.0
+
+ '@astrojs/sitemap@3.7.2':
+ dependencies:
+ sitemap: 9.0.1
+ stream-replace-string: 2.0.0
+ zod: 4.4.3
+
+ '@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))':
+ dependencies:
+ '@astrojs/markdown-remark': 7.1.1
+ '@astrojs/mdx': 5.0.4(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@astrojs/sitemap': 3.7.2
+ '@pagefind/default-ui': 1.5.2
+ '@types/hast': 3.0.4
+ '@types/js-yaml': 4.0.9
+ '@types/mdast': 4.0.4
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ astro-expressive-code: 0.42.0(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ bcp-47: 2.1.0
+ hast-util-from-html: 2.0.3
+ hast-util-select: 6.0.4
+ hast-util-to-string: 3.0.1
+ hastscript: 9.0.1
+ i18next: 26.1.0
+ js-yaml: 4.1.1
+ klona: 2.0.6
+ magic-string: 0.30.21
+ mdast-util-directive: 3.1.0
+ mdast-util-to-markdown: 2.1.2
+ mdast-util-to-string: 4.0.0
+ pagefind: 1.5.2
+ rehype: 13.0.2
+ rehype-format: 5.0.1
+ remark-directive: 4.0.0
+ ultrahtml: 1.6.0
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ '@astrojs/telemetry@3.3.2':
+ dependencies:
+ ci-info: 4.4.0
+ dset: 3.1.4
+ is-docker: 4.0.0
+ is-wsl: 3.1.1
+ which-pm-runs: 1.1.0
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/parser@7.29.3':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@braintree/sanitize-url@7.1.2': {}
+
+ '@capsizecss/unpack@4.0.0':
+ dependencies:
+ fontkitten: 1.0.3
+
+ '@chevrotain/types@11.1.2': {}
+
+ '@clack/core@1.3.1':
+ dependencies:
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
+ '@clack/prompts@1.4.0':
+ dependencies:
+ '@clack/core': 1.3.1
+ fast-string-width: 3.0.2
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
+ '@ctrl/tinycolor@4.2.0': {}
+
+ '@emnapi/runtime@1.9.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@esbuild/aix-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/android-arm@0.27.7':
+ optional: true
+
+ '@esbuild/android-x64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/darwin-x64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-arm@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/linux-loong64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.27.7':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.27.7':
+ optional: true
+
+ '@esbuild/linux-s390x@0.27.7':
+ optional: true
+
+ '@esbuild/linux-x64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.27.7':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/sunos-x64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-arm64@0.27.7':
+ optional: true
+
+ '@esbuild/win32-ia32@0.27.7':
+ optional: true
+
+ '@esbuild/win32-x64@0.27.7':
+ optional: true
+
+ '@expressive-code/core@0.42.0':
+ dependencies:
+ '@ctrl/tinycolor': 4.2.0
+ hast-util-select: 6.0.4
+ hast-util-to-html: 9.0.5
+ hast-util-to-text: 4.0.2
+ hastscript: 9.0.1
+ postcss: 8.5.14
+ postcss-nested: 6.2.0(postcss@8.5.14)
+ unist-util-visit: 5.1.0
+ unist-util-visit-parents: 6.0.2
+
+ '@expressive-code/plugin-frames@0.42.0':
+ dependencies:
+ '@expressive-code/core': 0.42.0
+
+ '@expressive-code/plugin-shiki@0.42.0':
+ dependencies:
+ '@expressive-code/core': 0.42.0
+ shiki: 4.0.2
+
+ '@expressive-code/plugin-text-markers@0.42.0':
+ dependencies:
+ '@expressive-code/core': 0.42.0
+
+ '@fontsource-variable/inter@5.2.8': {}
+
+ '@fontsource-variable/roboto-mono@5.2.9': {}
+
+ '@iconify/types@2.0.0': {}
+
+ '@iconify/utils@3.1.0':
+ dependencies:
+ '@antfu/install-pkg': 1.1.0
+ '@iconify/types': 2.0.0
+ mlly: 1.8.2
+
+ '@img/colour@1.1.0': {}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.9.2
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.3
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@mdx-js/mdx@3.1.1':
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdx': 2.0.13
+ acorn: 8.16.0
+ collapse-white-space: 2.1.0
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ estree-util-scope: 1.0.0
+ estree-walker: 3.0.3
+ hast-util-to-jsx-runtime: 2.3.6
+ markdown-extensions: 2.0.0
+ recma-build-jsx: 1.0.0
+ recma-jsx: 1.0.1(acorn@8.16.0)
+ recma-stringify: 1.0.0
+ rehype-recma: 1.0.0
+ remark-mdx: 3.1.1
+ remark-parse: 11.0.0
+ remark-rehype: 11.1.2
+ source-map: 0.7.6
+ unified: 11.0.5
+ unist-util-position-from-estree: 2.0.0
+ unist-util-stringify-position: 4.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@mermaid-js/parser@1.1.1':
+ dependencies:
+ '@chevrotain/types': 11.1.2
+
+ '@oslojs/encoding@1.1.0': {}
+
+ '@pagefind/darwin-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/darwin-x64@1.5.2':
+ optional: true
+
+ '@pagefind/default-ui@1.5.2': {}
+
+ '@pagefind/freebsd-x64@1.5.2':
+ optional: true
+
+ '@pagefind/linux-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/linux-x64@1.5.2':
+ optional: true
+
+ '@pagefind/windows-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/windows-x64@1.5.2':
+ optional: true
+
+ '@rollup/pluginutils@5.3.0(rollup@4.60.4)':
+ dependencies:
+ '@types/estree': 1.0.9
+ estree-walker: 2.0.2
+ picomatch: 4.0.4
+ optionalDependencies:
+ rollup: 4.60.4
+
+ '@rollup/rollup-android-arm-eabi@4.60.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.4':
+ optional: true
+
+ '@shikijs/core@4.0.2':
+ dependencies:
+ '@shikijs/primitive': 4.0.2
+ '@shikijs/types': 4.0.2
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+ hast-util-to-html: 9.0.5
+
+ '@shikijs/core@4.1.0':
+ dependencies:
+ '@shikijs/primitive': 4.1.0
+ '@shikijs/types': 4.1.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+ hast-util-to-html: 9.0.5
+
+ '@shikijs/engine-javascript@4.0.2':
+ dependencies:
+ '@shikijs/types': 4.0.2
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.5
+
+ '@shikijs/engine-javascript@4.1.0':
+ dependencies:
+ '@shikijs/types': 4.1.0
+ '@shikijs/vscode-textmate': 10.0.2
+ oniguruma-to-es: 4.3.6
+
+ '@shikijs/engine-oniguruma@4.0.2':
+ dependencies:
+ '@shikijs/types': 4.0.2
+ '@shikijs/vscode-textmate': 10.0.2
+
+ '@shikijs/engine-oniguruma@4.1.0':
+ dependencies:
+ '@shikijs/types': 4.1.0
+ '@shikijs/vscode-textmate': 10.0.2
+
+ '@shikijs/langs@4.0.2':
+ dependencies:
+ '@shikijs/types': 4.0.2
+
+ '@shikijs/langs@4.1.0':
+ dependencies:
+ '@shikijs/types': 4.1.0
+
+ '@shikijs/primitive@4.0.2':
+ dependencies:
+ '@shikijs/types': 4.0.2
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ '@shikijs/primitive@4.1.0':
+ dependencies:
+ '@shikijs/types': 4.1.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ '@shikijs/themes@4.0.2':
+ dependencies:
+ '@shikijs/types': 4.0.2
+
+ '@shikijs/themes@4.1.0':
+ dependencies:
+ '@shikijs/types': 4.1.0
+
+ '@shikijs/types@4.0.2':
+ dependencies:
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ '@shikijs/types@4.1.0':
+ dependencies:
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ '@shikijs/vscode-textmate@10.0.2': {}
+
+ '@types/braces@3.0.5': {}
+
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-axis@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-brush@3.0.6':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-chord@3.0.6': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-contour@3.0.6':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-delaunay@6.0.4': {}
+
+ '@types/d3-dispatch@3.0.7': {}
+
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-dsv@3.0.7': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-fetch@3.0.7':
+ dependencies:
+ '@types/d3-dsv': 3.0.7
+
+ '@types/d3-force@3.0.10': {}
+
+ '@types/d3-format@3.0.4': {}
+
+ '@types/d3-geo@3.1.0':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
+ '@types/d3-hierarchy@3.1.7': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-polygon@3.0.2': {}
+
+ '@types/d3-quadtree@3.0.6': {}
+
+ '@types/d3-random@3.0.3': {}
+
+ '@types/d3-scale-chromatic@3.1.0': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-selection@3.0.11': {}
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time-format@4.0.3': {}
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3@7.4.3':
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-axis': 3.0.6
+ '@types/d3-brush': 3.0.6
+ '@types/d3-chord': 3.0.6
+ '@types/d3-color': 3.1.3
+ '@types/d3-contour': 3.0.6
+ '@types/d3-delaunay': 6.0.4
+ '@types/d3-dispatch': 3.0.7
+ '@types/d3-drag': 3.0.7
+ '@types/d3-dsv': 3.0.7
+ '@types/d3-ease': 3.0.2
+ '@types/d3-fetch': 3.0.7
+ '@types/d3-force': 3.0.10
+ '@types/d3-format': 3.0.4
+ '@types/d3-geo': 3.1.0
+ '@types/d3-hierarchy': 3.1.7
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-path': 3.1.1
+ '@types/d3-polygon': 3.0.2
+ '@types/d3-quadtree': 3.0.6
+ '@types/d3-random': 3.0.3
+ '@types/d3-scale': 4.0.9
+ '@types/d3-scale-chromatic': 3.1.0
+ '@types/d3-selection': 3.0.11
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-time-format': 4.0.3
+ '@types/d3-timer': 3.0.2
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+
+ '@types/debug@4.1.13':
+ dependencies:
+ '@types/ms': 2.1.0
+
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
+ '@types/estree@1.0.8': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/geojson@7946.0.16': {}
+
+ '@types/hast@3.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/js-yaml@4.0.9': {}
+
+ '@types/mdast@4.0.4':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/mdx@2.0.13': {}
+
+ '@types/micromatch@4.0.10':
+ dependencies:
+ '@types/braces': 3.0.5
+
+ '@types/ms@2.1.0': {}
+
+ '@types/nlcst@2.0.3':
+ dependencies:
+ '@types/unist': 3.0.3
+
+ '@types/node@24.12.3':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/picomatch@4.0.3': {}
+
+ '@types/sax@1.2.7':
+ dependencies:
+ '@types/node': 24.12.3
+
+ '@types/trusted-types@2.0.7':
+ optional: true
+
+ '@types/unist@2.0.11': {}
+
+ '@types/unist@3.0.3': {}
+
+ '@ungap/structured-clone@1.3.0': {}
+
+ '@ungap/structured-clone@1.3.1': {}
+
+ '@upsetjs/venn.js@2.0.0':
+ optionalDependencies:
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ abbrev@4.0.0: {}
+
+ acorn-jsx@5.3.2(acorn@8.16.0):
+ dependencies:
+ acorn: 8.16.0
+
+ acorn@8.16.0: {}
+
+ ansi-escapes@7.3.0:
+ dependencies:
+ environment: 1.1.0
+
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ arg@5.0.2: {}
+
+ argparse@2.0.1: {}
+
+ aria-query@5.3.2: {}
+
+ array-iterate@2.0.1: {}
+
+ astring@1.9.0: {}
+
+ astro-expressive-code@0.42.0(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)):
+ dependencies:
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ rehype-expressive-code: 0.42.0
+
+ astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0):
+ dependencies:
+ '@astrojs/compiler': 4.0.0
+ '@astrojs/internal-helpers': 0.9.1
+ '@astrojs/markdown-remark': 7.1.2
+ '@astrojs/telemetry': 3.3.2
+ '@capsizecss/unpack': 4.0.0
+ '@clack/prompts': 1.4.0
+ '@oslojs/encoding': 1.1.0
+ '@rollup/pluginutils': 5.3.0(rollup@4.60.4)
+ aria-query: 5.3.2
+ axobject-query: 4.1.0
+ ci-info: 4.4.0
+ clsx: 2.1.1
+ common-ancestor-path: 2.0.0
+ cookie: 1.1.1
+ devalue: 5.8.1
+ diff: 8.0.4
+ dset: 3.1.4
+ es-module-lexer: 2.1.0
+ esbuild: 0.27.7
+ flattie: 1.1.1
+ fontace: 0.4.1
+ get-tsconfig: 5.0.0-beta.4
+ github-slugger: 2.0.0
+ html-escaper: 3.0.3
+ http-cache-semantics: 4.2.0
+ js-yaml: 4.1.1
+ jsonc-parser: 3.3.1
+ magic-string: 0.30.21
+ magicast: 0.5.3
+ mrmime: 2.0.1
+ neotraverse: 0.6.18
+ obug: 2.1.1
+ p-limit: 7.3.0
+ p-queue: 9.3.0
+ package-manager-detector: 1.6.0
+ piccolore: 0.1.3
+ picomatch: 4.0.4
+ rehype: 13.0.2
+ semver: 7.8.1
+ shiki: 4.1.0
+ smol-toml: 1.6.1
+ svgo: 4.0.1
+ tinyclip: 0.1.12
+ tinyexec: 1.2.2
+ tinyglobby: 0.2.16
+ ultrahtml: 1.6.0
+ unifont: 0.7.4
+ unist-util-visit: 5.1.0
+ unstorage: 1.17.5
+ vfile: 6.0.3
+ vite: 7.3.3(@types/node@24.12.3)(jiti@2.6.1)(yaml@2.9.0)
+ vitefu: 1.1.3(vite@7.3.3(@types/node@24.12.3)(jiti@2.6.1)(yaml@2.9.0))
+ xxhash-wasm: 1.1.0
+ yargs-parser: 22.0.0
+ zod: 4.4.3
+ optionalDependencies:
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@types/node'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - idb-keyval
+ - ioredis
+ - jiti
+ - less
+ - lightningcss
+ - rollup
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - uploadthing
+ - yaml
+
+ axobject-query@4.1.0: {}
+
+ bail@2.0.2: {}
+
+ bcp-47-match@2.0.3: {}
+
+ bcp-47@2.1.0:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+
+ boolbase@1.0.0: {}
+
+ braces@3.0.3:
+ dependencies:
+ fill-range: 7.1.1
+
+ ccount@2.0.1: {}
+
+ character-entities-html4@2.1.0: {}
+
+ character-entities-legacy@3.0.0: {}
+
+ character-entities@2.0.2: {}
+
+ character-reference-invalid@2.0.1: {}
+
+ chokidar@5.0.0:
+ dependencies:
+ readdirp: 5.0.0
+
+ chownr@3.0.0: {}
+
+ ci-info@4.4.0: {}
+
+ clsx@2.1.1: {}
+
+ collapse-white-space@2.1.0: {}
+
+ comma-separated-tokens@2.0.3: {}
+
+ commander@11.1.0: {}
+
+ commander@7.2.0: {}
+
+ commander@8.3.0: {}
+
+ common-ancestor-path@2.0.0: {}
+
+ confbox@0.1.8: {}
+
+ cookie-es@1.2.3: {}
+
+ cookie@1.1.1: {}
+
+ cose-base@1.0.3:
+ dependencies:
+ layout-base: 1.0.2
+
+ cose-base@2.2.0:
+ dependencies:
+ layout-base: 2.0.1
+
+ crossws@0.3.5:
+ dependencies:
+ uncrypto: 0.1.3
+
+ css-select@5.2.2:
+ dependencies:
+ boolbase: 1.0.0
+ css-what: 6.2.2
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ nth-check: 2.1.1
+
+ css-selector-parser@3.3.0: {}
+
+ css-tree@2.2.1:
+ dependencies:
+ mdn-data: 2.0.28
+ source-map-js: 1.2.1
+
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css-what@6.2.2: {}
+
+ cssesc@3.0.0: {}
+
+ csso@5.0.5:
+ dependencies:
+ css-tree: 2.2.1
+
+ cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.2):
+ dependencies:
+ cose-base: 1.0.3
+ cytoscape: 3.33.2
+
+ cytoscape-fcose@2.2.0(cytoscape@3.33.2):
+ dependencies:
+ cose-base: 2.2.0
+ cytoscape: 3.33.2
+
+ cytoscape@3.33.2: {}
+
+ d3-array@2.12.1:
+ dependencies:
+ internmap: 1.0.1
+
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-axis@3.0.0: {}
+
+ d3-brush@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3-chord@3.0.1:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-color@3.1.0: {}
+
+ d3-contour@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-delaunay@6.0.4:
+ dependencies:
+ delaunator: 5.1.0
+
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
+ d3-dsv@3.0.1:
+ dependencies:
+ commander: 7.2.0
+ iconv-lite: 0.6.3
+ rw: 1.3.3
+
+ d3-ease@3.0.1: {}
+
+ d3-fetch@3.0.1:
+ dependencies:
+ d3-dsv: 3.0.1
+
+ d3-force@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-timer: 3.0.1
+
+ d3-format@3.1.2: {}
+
+ d3-geo@3.1.1:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-hierarchy@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@1.0.9: {}
+
+ d3-path@3.1.0: {}
+
+ d3-polygon@3.0.1: {}
+
+ d3-quadtree@3.0.1: {}
+
+ d3-random@3.0.1: {}
+
+ d3-sankey@0.12.3:
+ dependencies:
+ d3-array: 2.12.1
+ d3-shape: 1.3.7
+
+ d3-scale-chromatic@3.1.0:
+ dependencies:
+ d3-color: 3.1.0
+ d3-interpolate: 3.0.1
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-selection@3.0.0: {}
+
+ d3-shape@1.3.7:
+ dependencies:
+ d3-path: 1.0.9
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
+ d3@7.9.0:
+ dependencies:
+ d3-array: 3.2.4
+ d3-axis: 3.0.0
+ d3-brush: 3.0.0
+ d3-chord: 3.0.1
+ d3-color: 3.1.0
+ d3-contour: 4.0.2
+ d3-delaunay: 6.0.4
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-dsv: 3.0.1
+ d3-ease: 3.0.1
+ d3-fetch: 3.0.1
+ d3-force: 3.0.0
+ d3-format: 3.1.2
+ d3-geo: 3.1.1
+ d3-hierarchy: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-path: 3.1.0
+ d3-polygon: 3.0.1
+ d3-quadtree: 3.0.1
+ d3-random: 3.0.1
+ d3-scale: 4.0.2
+ d3-scale-chromatic: 3.1.0
+ d3-selection: 3.0.0
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+ d3-timer: 3.0.1
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+ d3-zoom: 3.0.0
+
+ dagre-d3-es@7.0.14:
+ dependencies:
+ d3: 7.9.0
+ lodash-es: 4.18.1
+
+ dayjs@1.11.20: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decode-named-character-reference@1.3.0:
+ dependencies:
+ character-entities: 2.0.2
+
+ defu@6.1.7: {}
+
+ delaunator@5.1.0:
+ dependencies:
+ robust-predicates: 3.0.3
+
+ dequal@2.0.3: {}
+
+ destr@2.0.5: {}
+
+ detect-libc@2.1.2: {}
+
+ devalue@5.8.1: {}
+
+ devlop@1.1.0:
+ dependencies:
+ dequal: 2.0.3
+
+ diff@8.0.4: {}
+
+ direction@2.0.1: {}
+
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ dompurify@3.3.3:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
+ dset@3.1.4: {}
+
+ entities@4.5.0: {}
+
+ entities@6.0.1: {}
+
+ env-paths@2.2.1: {}
+
+ environment@1.1.0: {}
+
+ es-module-lexer@2.1.0: {}
+
+ es-toolkit@1.46.1: {}
+
+ esast-util-from-estree@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ devlop: 1.1.0
+ estree-util-visit: 2.0.0
+ unist-util-position-from-estree: 2.0.0
+
+ esast-util-from-js@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ acorn: 8.16.0
+ esast-util-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
+ esbuild@0.27.7:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.27.7
+ '@esbuild/android-arm': 0.27.7
+ '@esbuild/android-arm64': 0.27.7
+ '@esbuild/android-x64': 0.27.7
+ '@esbuild/darwin-arm64': 0.27.7
+ '@esbuild/darwin-x64': 0.27.7
+ '@esbuild/freebsd-arm64': 0.27.7
+ '@esbuild/freebsd-x64': 0.27.7
+ '@esbuild/linux-arm': 0.27.7
+ '@esbuild/linux-arm64': 0.27.7
+ '@esbuild/linux-ia32': 0.27.7
+ '@esbuild/linux-loong64': 0.27.7
+ '@esbuild/linux-mips64el': 0.27.7
+ '@esbuild/linux-ppc64': 0.27.7
+ '@esbuild/linux-riscv64': 0.27.7
+ '@esbuild/linux-s390x': 0.27.7
+ '@esbuild/linux-x64': 0.27.7
+ '@esbuild/netbsd-arm64': 0.27.7
+ '@esbuild/netbsd-x64': 0.27.7
+ '@esbuild/openbsd-arm64': 0.27.7
+ '@esbuild/openbsd-x64': 0.27.7
+ '@esbuild/openharmony-arm64': 0.27.7
+ '@esbuild/sunos-x64': 0.27.7
+ '@esbuild/win32-arm64': 0.27.7
+ '@esbuild/win32-ia32': 0.27.7
+ '@esbuild/win32-x64': 0.27.7
+
+ escape-string-regexp@5.0.0: {}
+
+ estree-util-attach-comments@3.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ estree-util-build-jsx@3.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ estree-walker: 3.0.3
+
+ estree-util-is-identifier-name@3.0.0: {}
+
+ estree-util-scope@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+
+ estree-util-to-js@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ astring: 1.9.0
+ source-map: 0.7.6
+
+ estree-util-visit@2.0.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/unist': 3.0.3
+
+ estree-walker@2.0.2: {}
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
+ eventemitter3@5.0.4: {}
+
+ exponential-backoff@3.1.3: {}
+
+ expressive-code@0.42.0:
+ dependencies:
+ '@expressive-code/core': 0.42.0
+ '@expressive-code/plugin-frames': 0.42.0
+ '@expressive-code/plugin-shiki': 0.42.0
+ '@expressive-code/plugin-text-markers': 0.42.0
+
+ extend@3.0.2: {}
+
+ fast-string-truncated-width@3.0.3: {}
+
+ fast-string-width@3.0.2:
+ dependencies:
+ fast-string-truncated-width: 3.0.3
+
+ fast-wrap-ansi@0.2.2:
+ dependencies:
+ fast-string-width: 3.0.2
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ fill-range@7.1.1:
+ dependencies:
+ to-regex-range: 5.0.1
+
+ flattie@1.1.1: {}
+
+ fontace@0.4.1:
+ dependencies:
+ fontkitten: 1.0.3
+
+ fontkitten@1.0.3:
+ dependencies:
+ tiny-inflate: 1.0.3
+
+ fsevents@2.3.3:
+ optional: true
+
+ get-tsconfig@5.0.0-beta.4:
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+
+ github-slugger@2.0.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ h3@1.15.11:
+ dependencies:
+ cookie-es: 1.2.3
+ crossws: 0.3.5
+ defu: 6.1.7
+ destr: 2.0.5
+ iron-webcrypto: 1.2.1
+ node-mock-http: 1.0.4
+ radix3: 1.1.2
+ ufo: 1.6.4
+ uncrypto: 0.1.3
+
+ hachure-fill@0.5.2: {}
+
+ has-flag@5.0.1: {}
+
+ hast-util-embedded@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-is-element: 3.0.0
+
+ hast-util-format@1.1.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-embedded: 3.0.0
+ hast-util-minify-whitespace: 1.0.1
+ hast-util-phrasing: 3.0.1
+ hast-util-whitespace: 3.0.0
+ html-whitespace-sensitive-tag-names: 3.0.1
+ unist-util-visit-parents: 6.0.2
+
+ hast-util-from-html@2.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ devlop: 1.1.0
+ hast-util-from-parse5: 8.0.3
+ parse5: 7.3.0
+ vfile: 6.0.3
+ vfile-message: 4.0.3
+
+ hast-util-from-parse5@8.0.3:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ hastscript: 9.0.1
+ property-information: 7.1.0
+ vfile: 6.0.3
+ vfile-location: 5.0.3
+ web-namespaces: 2.0.1
+
+ hast-util-has-property@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-is-body-ok-link@3.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-is-element@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-minify-whitespace@1.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-embedded: 3.0.0
+ hast-util-is-element: 3.0.0
+ hast-util-whitespace: 3.0.0
+ unist-util-is: 6.0.1
+
+ hast-util-parse-selector@4.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-phrasing@3.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-embedded: 3.0.0
+ hast-util-has-property: 3.0.0
+ hast-util-is-body-ok-link: 3.0.1
+ hast-util-is-element: 3.0.0
+
+ hast-util-raw@9.1.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ '@ungap/structured-clone': 1.3.0
+ hast-util-from-parse5: 8.0.3
+ hast-util-to-parse5: 8.0.1
+ html-void-elements: 3.0.0
+ mdast-util-to-hast: 13.2.1
+ parse5: 7.3.0
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
+ hast-util-select@6.0.4:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ bcp-47-match: 2.0.3
+ comma-separated-tokens: 2.0.3
+ css-selector-parser: 3.3.0
+ devlop: 1.1.0
+ direction: 2.0.1
+ hast-util-has-property: 3.0.0
+ hast-util-to-string: 3.0.1
+ hast-util-whitespace: 3.0.0
+ nth-check: 2.1.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ hast-util-to-estree@3.1.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-attach-comments: 3.0.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ zwitch: 2.0.4
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-to-html@9.0.5:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ comma-separated-tokens: 2.0.3
+ hast-util-whitespace: 3.0.0
+ html-void-elements: 3.0.0
+ mdast-util-to-hast: 13.2.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ stringify-entities: 4.0.4
+ zwitch: 2.0.4
+
+ hast-util-to-jsx-runtime@2.3.6:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ hast-util-whitespace: 3.0.0
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ style-to-js: 1.1.21
+ unist-util-position: 5.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ hast-util-to-mdast@10.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.1
+ hast-util-phrasing: 3.0.1
+ hast-util-to-html: 9.0.5
+ hast-util-to-text: 4.0.2
+ hast-util-whitespace: 3.0.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-hast: 13.2.1
+ mdast-util-to-string: 4.0.0
+ rehype-minify-whitespace: 6.0.2
+ trim-trailing-lines: 2.1.0
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+
+ hast-util-to-parse5@8.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ devlop: 1.1.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+ web-namespaces: 2.0.1
+ zwitch: 2.0.4
+
+ hast-util-to-string@3.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hast-util-to-text@4.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/unist': 3.0.3
+ hast-util-is-element: 3.0.0
+ unist-util-find-after: 5.0.0
+
+ hast-util-whitespace@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+
+ hastscript@9.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ comma-separated-tokens: 2.0.3
+ hast-util-parse-selector: 4.0.0
+ property-information: 7.1.0
+ space-separated-tokens: 2.0.2
+
+ html-escaper@3.0.3: {}
+
+ html-void-elements@3.0.0: {}
+
+ html-whitespace-sensitive-tag-names@3.0.1: {}
+
+ http-cache-semantics@4.2.0: {}
+
+ i18next@26.1.0: {}
+
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+
+ inline-style-parser@0.2.7: {}
+
+ internmap@1.0.1: {}
+
+ internmap@2.0.3: {}
+
+ iron-webcrypto@1.2.1: {}
+
+ is-absolute-url@4.0.1: {}
+
+ is-absolute-url@5.0.0: {}
+
+ is-alphabetical@2.0.1: {}
+
+ is-alphanumerical@2.0.1:
+ dependencies:
+ is-alphabetical: 2.0.1
+ is-decimal: 2.0.1
+
+ is-decimal@2.0.1: {}
+
+ is-docker@3.0.0: {}
+
+ is-docker@4.0.0: {}
+
+ is-hexadecimal@2.0.1: {}
+
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
+ is-number@7.0.0: {}
+
+ is-plain-obj@4.1.0: {}
+
+ is-wsl@3.1.1:
+ dependencies:
+ is-inside-container: 1.0.0
+
+ isexe@4.0.0: {}
+
+ jiti@2.6.1:
+ optional: true
+
+ js-yaml@4.1.1:
+ dependencies:
+ argparse: 2.0.1
+
+ jsonc-parser@3.3.1: {}
+
+ katex@0.16.45:
+ dependencies:
+ commander: 8.3.0
+
+ khroma@2.1.0: {}
+
+ klona@2.0.6: {}
+
+ layout-base@1.0.2: {}
+
+ layout-base@2.0.1: {}
+
+ lodash-es@4.18.1: {}
+
+ longest-streak@3.1.0: {}
+
+ lru-cache@11.5.0: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ magicast@0.5.3:
+ dependencies:
+ '@babel/parser': 7.29.3
+ '@babel/types': 7.29.0
+ source-map-js: 1.2.1
+
+ markdown-extensions@2.0.0: {}
+
+ markdown-table@3.0.4: {}
+
+ marked@16.4.2: {}
+
+ mdast-util-definitions@6.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ unist-util-visit: 5.1.0
+
+ mdast-util-directive@3.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-visit-parents: 6.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-find-and-replace@3.0.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ escape-string-regexp: 5.0.0
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ mdast-util-from-markdown@2.0.3:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ mdast-util-to-string: 4.0.0
+ micromark: 4.0.2
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-decode-string: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-stringify-position: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-autolink-literal@2.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-find-and-replace: 3.0.2
+ micromark-util-character: 2.1.1
+
+ mdast-util-gfm-footnote@2.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ micromark-util-normalize-identifier: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-strikethrough@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-table@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ markdown-table: 3.0.4
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm-task-list-item@2.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-gfm@3.1.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-gfm-autolink-literal: 2.0.1
+ mdast-util-gfm-footnote: 2.1.0
+ mdast-util-gfm-strikethrough: 2.0.0
+ mdast-util-gfm-table: 2.0.0
+ mdast-util-gfm-task-list-item: 2.0.0
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-expression@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx-jsx@3.2.0:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ ccount: 2.0.1
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ parse-entities: 4.0.2
+ stringify-entities: 4.0.4
+ unist-util-stringify-position: 4.0.0
+ vfile-message: 4.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdx@3.0.0:
+ dependencies:
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-mdx-expression: 2.0.1
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-mdxjs-esm: 2.0.1
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-mdxjs-esm@2.0.1:
+ dependencies:
+ '@types/estree-jsx': 1.0.5
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ devlop: 1.1.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ mdast-util-phrasing@4.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ unist-util-is: 6.0.1
+
+ mdast-util-to-hast@13.2.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ '@ungap/structured-clone': 1.3.0
+ devlop: 1.1.0
+ micromark-util-sanitize-uri: 2.0.1
+ trim-lines: 3.0.1
+ unist-util-position: 5.0.0
+ unist-util-visit: 5.1.0
+ vfile: 6.0.3
+
+ mdast-util-to-markdown@2.1.2:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/unist': 3.0.3
+ longest-streak: 3.1.0
+ mdast-util-phrasing: 4.1.0
+ mdast-util-to-string: 4.0.0
+ micromark-util-classify-character: 2.0.1
+ micromark-util-decode-string: 2.0.1
+ unist-util-visit: 5.1.0
+ zwitch: 2.0.4
+
+ mdast-util-to-string@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+
+ mdn-data@2.0.28: {}
+
+ mdn-data@2.27.1: {}
+
+ mermaid@11.15.0:
+ dependencies:
+ '@braintree/sanitize-url': 7.1.2
+ '@iconify/utils': 3.1.0
+ '@mermaid-js/parser': 1.1.1
+ '@types/d3': 7.4.3
+ '@upsetjs/venn.js': 2.0.0
+ cytoscape: 3.33.2
+ cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.2)
+ cytoscape-fcose: 2.2.0(cytoscape@3.33.2)
+ d3: 7.9.0
+ d3-sankey: 0.12.3
+ dagre-d3-es: 7.0.14
+ dayjs: 1.11.20
+ dompurify: 3.3.3
+ es-toolkit: 1.46.1
+ katex: 0.16.45
+ khroma: 2.1.0
+ marked: 16.4.2
+ roughjs: 4.6.6
+ stylis: 4.3.6
+ ts-dedent: 2.2.0
+ uuid: 11.1.0
+
+ micromark-core-commonmark@2.0.3:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-factory-destination: 2.0.1
+ micromark-factory-label: 2.0.1
+ micromark-factory-space: 2.0.1
+ micromark-factory-title: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-html-tag-name: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-directive@4.0.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-factory-whitespace: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ parse-entities: 4.0.2
+
+ micromark-extension-gfm-autolink-literal@2.1.0:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-footnote@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-strikethrough@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-classify-character: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-table@2.1.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-tagfilter@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm-task-list-item@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-gfm@3.0.0:
+ dependencies:
+ micromark-extension-gfm-autolink-literal: 2.1.0
+ micromark-extension-gfm-footnote: 2.1.0
+ micromark-extension-gfm-strikethrough: 2.1.0
+ micromark-extension-gfm-table: 2.1.1
+ micromark-extension-gfm-tagfilter: 2.0.0
+ micromark-extension-gfm-task-list-item: 2.1.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdx-expression@3.0.1:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-factory-mdx-expression: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdx-jsx@3.0.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ estree-util-is-identifier-name: 3.0.0
+ micromark-factory-mdx-expression: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ vfile-message: 4.0.3
+
+ micromark-extension-mdx-md@2.0.0:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-mdxjs-esm@3.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-position-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
+ micromark-extension-mdxjs@3.0.0:
+ dependencies:
+ acorn: 8.16.0
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ micromark-extension-mdx-expression: 3.0.1
+ micromark-extension-mdx-jsx: 3.0.2
+ micromark-extension-mdx-md: 2.0.0
+ micromark-extension-mdxjs-esm: 3.0.0
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-destination@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-label@2.0.1:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-mdx-expression@2.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ devlop: 1.1.0
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-events-to-acorn: 2.0.3
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ unist-util-position-from-estree: 2.0.0
+ vfile-message: 4.0.3
+
+ micromark-factory-space@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-title@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-factory-whitespace@2.0.1:
+ dependencies:
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-character@2.1.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-chunked@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-classify-character@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-combine-extensions@2.0.1:
+ dependencies:
+ micromark-util-chunked: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-decode-numeric-character-reference@2.0.2:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-decode-string@2.0.1:
+ dependencies:
+ decode-named-character-reference: 1.3.0
+ micromark-util-character: 2.1.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-encode@2.0.1: {}
+
+ micromark-util-events-to-acorn@2.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/unist': 3.0.3
+ devlop: 1.1.0
+ estree-util-visit: 2.0.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ vfile-message: 4.0.3
+
+ micromark-util-html-tag-name@2.0.1: {}
+
+ micromark-util-normalize-identifier@2.0.1:
+ dependencies:
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-resolve-all@2.0.1:
+ dependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-util-sanitize-uri@2.0.1:
+ dependencies:
+ micromark-util-character: 2.1.1
+ micromark-util-encode: 2.0.1
+ micromark-util-symbol: 2.0.1
+
+ micromark-util-subtokenize@2.1.0:
+ dependencies:
+ devlop: 1.1.0
+ micromark-util-chunked: 2.0.1
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+
+ micromark-util-symbol@2.0.1: {}
+
+ micromark-util-types@2.0.2: {}
+
+ micromark@4.0.2:
+ dependencies:
+ '@types/debug': 4.1.13
+ debug: 4.4.3
+ decode-named-character-reference: 1.3.0
+ devlop: 1.1.0
+ micromark-core-commonmark: 2.0.3
+ micromark-factory-space: 2.0.1
+ micromark-util-character: 2.1.1
+ micromark-util-chunked: 2.0.1
+ micromark-util-combine-extensions: 2.0.1
+ micromark-util-decode-numeric-character-reference: 2.0.2
+ micromark-util-encode: 2.0.1
+ micromark-util-normalize-identifier: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-sanitize-uri: 2.0.1
+ micromark-util-subtokenize: 2.1.0
+ micromark-util-symbol: 2.0.1
+ micromark-util-types: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.2
+
+ minipass@7.1.3: {}
+
+ minizlib@3.1.0:
+ dependencies:
+ minipass: 7.1.3
+
+ mlly@1.8.2:
+ dependencies:
+ acorn: 8.16.0
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ ufo: 1.6.3
+
+ mrmime@2.0.1: {}
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.12: {}
+
+ neotraverse@0.6.18: {}
+
+ nlcst-to-string@4.0.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+
+ node-addon-api@8.8.0: {}
+
+ node-fetch-native@1.6.7: {}
+
+ node-gyp@12.3.0:
+ dependencies:
+ env-paths: 2.2.1
+ exponential-backoff: 3.1.3
+ graceful-fs: 4.2.11
+ nopt: 9.0.0
+ proc-log: 6.1.0
+ semver: 7.8.1
+ tar: 7.5.15
+ tinyglobby: 0.2.16
+ undici: 6.25.0
+ which: 6.0.1
+
+ node-mock-http@1.0.4: {}
+
+ nopt@9.0.0:
+ dependencies:
+ abbrev: 4.0.0
+
+ normalize-path@3.0.0: {}
+
+ nth-check@2.1.1:
+ dependencies:
+ boolbase: 1.0.0
+
+ obug@2.1.1: {}
+
+ ofetch@1.5.1:
+ dependencies:
+ destr: 2.0.5
+ node-fetch-native: 1.6.7
+ ufo: 1.6.4
+
+ ohash@2.0.11: {}
+
+ oniguruma-parser@0.12.1: {}
+
+ oniguruma-parser@0.12.2: {}
+
+ oniguruma-to-es@4.3.5:
+ dependencies:
+ oniguruma-parser: 0.12.1
+ regex: 6.1.0
+ regex-recursion: 6.0.2
+
+ oniguruma-to-es@4.3.6:
+ dependencies:
+ oniguruma-parser: 0.12.2
+ regex: 6.1.0
+ regex-recursion: 6.0.2
+
+ p-limit@7.3.0:
+ dependencies:
+ yocto-queue: 1.2.2
+
+ p-queue@9.3.0:
+ dependencies:
+ eventemitter3: 5.0.4
+ p-timeout: 7.0.1
+
+ p-timeout@7.0.1: {}
+
+ package-manager-detector@1.6.0: {}
+
+ pagefind@1.5.2:
+ optionalDependencies:
+ '@pagefind/darwin-arm64': 1.5.2
+ '@pagefind/darwin-x64': 1.5.2
+ '@pagefind/freebsd-x64': 1.5.2
+ '@pagefind/linux-arm64': 1.5.2
+ '@pagefind/linux-x64': 1.5.2
+ '@pagefind/windows-arm64': 1.5.2
+ '@pagefind/windows-x64': 1.5.2
+
+ parse-entities@4.0.2:
+ dependencies:
+ '@types/unist': 2.0.11
+ character-entities-legacy: 3.0.0
+ character-reference-invalid: 2.0.1
+ decode-named-character-reference: 1.3.0
+ is-alphanumerical: 2.0.1
+ is-decimal: 2.0.1
+ is-hexadecimal: 2.0.1
+
+ parse-latin@7.0.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+ '@types/unist': 3.0.3
+ nlcst-to-string: 4.0.0
+ unist-util-modify-children: 4.0.0
+ unist-util-visit-children: 3.0.0
+ vfile: 6.0.3
+
+ parse5@7.3.0:
+ dependencies:
+ entities: 6.0.1
+
+ path-data-parser@0.1.0: {}
+
+ pathe@2.0.3: {}
+
+ piccolore@0.1.3: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@2.3.2: {}
+
+ picomatch@4.0.4: {}
+
+ pkg-types@1.3.1:
+ dependencies:
+ confbox: 0.1.8
+ mlly: 1.8.2
+ pathe: 2.0.3
+
+ points-on-curve@0.2.0: {}
+
+ points-on-path@0.2.1:
+ dependencies:
+ path-data-parser: 0.1.0
+ points-on-curve: 0.2.0
+
+ postcss-nested@6.2.0(postcss@8.5.14):
+ dependencies:
+ postcss: 8.5.14
+ postcss-selector-parser: 6.1.2
+
+ postcss-selector-parser@6.1.2:
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+
+ postcss@8.5.14:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prismjs@1.30.0: {}
+
+ proc-log@6.1.0: {}
+
+ property-information@7.1.0: {}
+
+ radix3@1.1.2: {}
+
+ readdirp@5.0.0: {}
+
+ recma-build-jsx@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-util-build-jsx: 3.0.1
+ vfile: 6.0.3
+
+ recma-jsx@1.0.1(acorn@8.16.0):
+ dependencies:
+ acorn: 8.16.0
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ estree-util-to-js: 2.0.0
+ recma-parse: 1.0.0
+ recma-stringify: 1.0.0
+ unified: 11.0.5
+
+ recma-parse@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ esast-util-from-js: 2.0.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ recma-stringify@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-util-to-js: 2.0.0
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ regex-recursion@6.0.2:
+ dependencies:
+ regex-utilities: 2.3.0
+
+ regex-utilities@2.3.0: {}
+
+ regex@6.1.0:
+ dependencies:
+ regex-utilities: 2.3.0
+
+ rehype-expressive-code@0.42.0:
+ dependencies:
+ expressive-code: 0.42.0
+
+ rehype-external-links@3.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@ungap/structured-clone': 1.3.0
+ hast-util-is-element: 3.0.0
+ is-absolute-url: 4.0.1
+ space-separated-tokens: 2.0.2
+ unist-util-visit: 5.1.0
+
+ rehype-format@5.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-format: 1.1.0
+
+ rehype-minify-whitespace@6.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-minify-whitespace: 1.0.1
+
+ rehype-parse@9.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-from-html: 2.0.3
+ unified: 11.0.5
+
+ rehype-raw@7.0.0:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-raw: 9.1.0
+ vfile: 6.0.3
+
+ rehype-recma@1.0.0:
+ dependencies:
+ '@types/estree': 1.0.8
+ '@types/hast': 3.0.4
+ hast-util-to-estree: 3.1.3
+ transitivePeerDependencies:
+ - supports-color
+
+ rehype-remark@10.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ hast-util-to-mdast: 10.1.2
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ rehype-stringify@10.0.1:
+ dependencies:
+ '@types/hast': 3.0.4
+ hast-util-to-html: 9.0.5
+ unified: 11.0.5
+
+ rehype@13.0.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ rehype-parse: 9.0.1
+ rehype-stringify: 10.0.1
+ unified: 11.0.5
+
+ remark-directive@4.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-directive: 3.1.0
+ micromark-extension-directive: 4.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-gfm@4.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-gfm: 3.1.0
+ micromark-extension-gfm: 3.0.0
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-mdx@3.1.1:
+ dependencies:
+ mdast-util-mdx: 3.0.0
+ micromark-extension-mdxjs: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-parse@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-from-markdown: 2.0.3
+ micromark-util-types: 2.0.2
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ remark-rehype@11.1.2:
+ dependencies:
+ '@types/hast': 3.0.4
+ '@types/mdast': 4.0.4
+ mdast-util-to-hast: 13.2.1
+ unified: 11.0.5
+ vfile: 6.0.3
+
+ remark-smartypants@3.0.2:
+ dependencies:
+ retext: 9.0.0
+ retext-smartypants: 6.2.0
+ unified: 11.0.5
+ unist-util-visit: 5.1.0
+
+ remark-stringify@11.0.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ mdast-util-to-markdown: 2.1.2
+ unified: 11.0.5
+
+ resolve-pkg-maps@1.0.0: {}
+
+ retext-latin@4.0.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+ parse-latin: 7.0.0
+ unified: 11.0.5
+
+ retext-smartypants@6.2.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+ nlcst-to-string: 4.0.0
+ unist-util-visit: 5.1.0
+
+ retext-stringify@4.0.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+ nlcst-to-string: 4.0.0
+ unified: 11.0.5
+
+ retext@9.0.0:
+ dependencies:
+ '@types/nlcst': 2.0.3
+ retext-latin: 4.0.0
+ retext-stringify: 4.0.0
+ unified: 11.0.5
+
+ robust-predicates@3.0.3: {}
+
+ rollup@4.60.4:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.4
+ '@rollup/rollup-android-arm64': 4.60.4
+ '@rollup/rollup-darwin-arm64': 4.60.4
+ '@rollup/rollup-darwin-x64': 4.60.4
+ '@rollup/rollup-freebsd-arm64': 4.60.4
+ '@rollup/rollup-freebsd-x64': 4.60.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.4
+ '@rollup/rollup-linux-arm64-gnu': 4.60.4
+ '@rollup/rollup-linux-arm64-musl': 4.60.4
+ '@rollup/rollup-linux-loong64-gnu': 4.60.4
+ '@rollup/rollup-linux-loong64-musl': 4.60.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.4
+ '@rollup/rollup-linux-ppc64-musl': 4.60.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.4
+ '@rollup/rollup-linux-riscv64-musl': 4.60.4
+ '@rollup/rollup-linux-s390x-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-gnu': 4.60.4
+ '@rollup/rollup-linux-x64-musl': 4.60.4
+ '@rollup/rollup-openbsd-x64': 4.60.4
+ '@rollup/rollup-openharmony-arm64': 4.60.4
+ '@rollup/rollup-win32-arm64-msvc': 4.60.4
+ '@rollup/rollup-win32-ia32-msvc': 4.60.4
+ '@rollup/rollup-win32-x64-gnu': 4.60.4
+ '@rollup/rollup-win32-x64-msvc': 4.60.4
+ fsevents: 2.3.3
+
+ roughjs@4.6.6:
+ dependencies:
+ hachure-fill: 0.5.2
+ path-data-parser: 0.1.0
+ points-on-curve: 0.2.0
+ points-on-path: 0.2.1
+
+ rw@1.3.3: {}
+
+ safer-buffer@2.1.2: {}
+
+ sax@1.6.0: {}
+
+ semver@7.7.4: {}
+
+ semver@7.8.1: {}
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.7.4
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+
+ shiki@4.0.2:
+ dependencies:
+ '@shikijs/core': 4.0.2
+ '@shikijs/engine-javascript': 4.0.2
+ '@shikijs/engine-oniguruma': 4.0.2
+ '@shikijs/langs': 4.0.2
+ '@shikijs/themes': 4.0.2
+ '@shikijs/types': 4.0.2
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ shiki@4.1.0:
+ dependencies:
+ '@shikijs/core': 4.1.0
+ '@shikijs/engine-javascript': 4.1.0
+ '@shikijs/engine-oniguruma': 4.1.0
+ '@shikijs/langs': 4.1.0
+ '@shikijs/themes': 4.1.0
+ '@shikijs/types': 4.1.0
+ '@shikijs/vscode-textmate': 10.0.2
+ '@types/hast': 3.0.4
+
+ sisteransi@1.0.5: {}
+
+ sitemap@9.0.1:
+ dependencies:
+ '@types/node': 24.12.3
+ '@types/sax': 1.2.7
+ arg: 5.0.2
+ sax: 1.6.0
+
+ smol-toml@1.6.1: {}
+
+ source-map-js@1.2.1: {}
+
+ source-map@0.7.6: {}
+
+ space-separated-tokens@2.0.2: {}
+
+ starlight-changelogs@0.5.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)):
+ dependencies:
+ '@ascorbic/loader-utils': 1.0.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@astrojs/starlight': 0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ github-slugger: 2.0.0
+ mdast-util-from-markdown: 2.0.3
+ mdast-util-to-markdown: 2.1.2
+ mdast-util-to-string: 4.0.0
+ unist-util-visit: 5.1.0
+ transitivePeerDependencies:
+ - astro
+ - supports-color
+
+ starlight-contextual-menu@0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))(starlight-markdown@0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))):
+ dependencies:
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ starlight-markdown: 0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+
+ starlight-links-validator@0.24.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)):
+ dependencies:
+ '@astrojs/starlight': 0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@types/picomatch': 4.0.3
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ github-slugger: 2.0.0
+ hast-util-from-html: 2.0.3
+ is-absolute-url: 5.0.0
+ mdast-util-mdx-jsx: 3.2.0
+ mdast-util-to-hast: 13.2.1
+ picomatch: 4.0.4
+ terminal-link: 5.0.0
+ unist-util-visit: 5.1.0
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - supports-color
+
+ starlight-llms-txt@0.10.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)))(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)):
+ dependencies:
+ '@astrojs/mdx': 5.0.6(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@astrojs/starlight': 0.39.2(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0))
+ '@types/hast': 3.0.4
+ '@types/micromatch': 4.0.10
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+ github-slugger: 2.0.0
+ hast-util-select: 6.0.4
+ micromatch: 4.0.8
+ rehype-parse: 9.0.1
+ rehype-remark: 10.0.1
+ remark-gfm: 4.0.1
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ unist-util-remove: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ starlight-markdown@0.1.5(astro@6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)):
+ dependencies:
+ astro: 6.3.7(@types/node@24.12.3)(jiti@2.6.1)(rollup@4.60.4)(yaml@2.9.0)
+
+ stream-replace-string@2.0.0: {}
+
+ stringify-entities@4.0.4:
+ dependencies:
+ character-entities-html4: 2.1.0
+ character-entities-legacy: 3.0.0
+
+ style-to-js@1.1.21:
+ dependencies:
+ style-to-object: 1.0.14
+
+ style-to-object@1.0.14:
+ dependencies:
+ inline-style-parser: 0.2.7
+
+ stylis@4.3.6: {}
+
+ supports-color@10.2.2: {}
+
+ supports-hyperlinks@4.4.0:
+ dependencies:
+ has-flag: 5.0.1
+ supports-color: 10.2.2
+
+ svgo@4.0.1:
+ dependencies:
+ commander: 11.1.0
+ css-select: 5.2.2
+ css-tree: 3.2.1
+ css-what: 6.2.2
+ csso: 5.0.5
+ picocolors: 1.1.1
+ sax: 1.6.0
+
+ tar@7.5.15:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.3
+ minizlib: 3.1.0
+ yallist: 5.0.0
+
+ terminal-link@5.0.0:
+ dependencies:
+ ansi-escapes: 7.3.0
+ supports-hyperlinks: 4.4.0
+
+ tiny-inflate@1.0.3: {}
+
+ tinyclip@0.1.12: {}
+
+ tinyexec@1.0.4: {}
+
+ tinyexec@1.2.2: {}
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ to-regex-range@5.0.1:
+ dependencies:
+ is-number: 7.0.0
+
+ trim-lines@3.0.1: {}
+
+ trim-trailing-lines@2.1.0: {}
+
+ trough@2.2.0: {}
+
+ ts-dedent@2.2.0: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ ufo@1.6.3: {}
+
+ ufo@1.6.4: {}
+
+ ultrahtml@1.6.0: {}
+
+ uncrypto@0.1.3: {}
+
+ undici-types@7.16.0: {}
+
+ undici@6.25.0: {}
+
+ unified@11.0.5:
+ dependencies:
+ '@types/unist': 3.0.3
+ bail: 2.0.2
+ devlop: 1.1.0
+ extend: 3.0.2
+ is-plain-obj: 4.1.0
+ trough: 2.2.0
+ vfile: 6.0.3
+
+ unifont@0.7.4:
+ dependencies:
+ css-tree: 3.2.1
+ ofetch: 1.5.1
+ ohash: 2.0.11
+
+ unist-util-find-after@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-is@6.0.1:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-modify-children@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ array-iterate: 2.0.1
+
+ unist-util-position-from-estree@2.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-remove-position@5.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-visit: 5.1.0
+
+ unist-util-remove@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ unist-util-stringify-position@4.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-children@3.0.0:
+ dependencies:
+ '@types/unist': 3.0.3
+
+ unist-util-visit-parents@6.0.2:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+
+ unist-util-visit@5.1.0:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-is: 6.0.1
+ unist-util-visit-parents: 6.0.2
+
+ unstorage@1.17.5:
+ dependencies:
+ anymatch: 3.1.3
+ chokidar: 5.0.0
+ destr: 2.0.5
+ h3: 1.15.11
+ lru-cache: 11.5.0
+ node-fetch-native: 1.6.7
+ ofetch: 1.5.1
+ ufo: 1.6.4
+
+ util-deprecate@1.0.2: {}
+
+ uuid@11.1.0: {}
+
+ vfile-location@5.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile: 6.0.3
+
+ vfile-message@4.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ unist-util-stringify-position: 4.0.0
+
+ vfile@6.0.3:
+ dependencies:
+ '@types/unist': 3.0.3
+ vfile-message: 4.0.3
+
+ vite@7.3.3(@types/node@24.12.3)(jiti@2.6.1)(yaml@2.9.0):
+ dependencies:
+ esbuild: 0.27.7
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rollup: 4.60.4
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 24.12.3
+ fsevents: 2.3.3
+ jiti: 2.6.1
+ yaml: 2.9.0
+
+ vitefu@1.1.3(vite@7.3.3(@types/node@24.12.3)(jiti@2.6.1)(yaml@2.9.0)):
+ optionalDependencies:
+ vite: 7.3.3(@types/node@24.12.3)(jiti@2.6.1)(yaml@2.9.0)
+
+ web-namespaces@2.0.1: {}
+
+ which-pm-runs@1.1.0: {}
+
+ which@6.0.1:
+ dependencies:
+ isexe: 4.0.0
+
+ xxhash-wasm@1.1.0: {}
+
+ yallist@5.0.0: {}
+
+ yaml@2.9.0: {}
+
+ yargs-parser@22.0.0: {}
+
+ yocto-queue@1.2.2: {}
+
+ zod@4.4.3: {}
+
+ zwitch@2.0.4: {}
diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml
new file mode 100644
index 00000000000..d0b7dbe2294
--- /dev/null
+++ b/docs/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+onlyBuiltDependencies:
+ - esbuild
+ - sharp
diff --git a/docs/public/CNAME b/docs/public/CNAME
new file mode 100644
index 00000000000..91547b2904f
--- /dev/null
+++ b/docs/public/CNAME
@@ -0,0 +1 @@
+invoke.ai
diff --git a/docs/public/coverimage.png b/docs/public/coverimage.png
new file mode 100644
index 00000000000..6586d8ba738
Binary files /dev/null and b/docs/public/coverimage.png differ
diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg
new file mode 100644
index 00000000000..28d6a6d08e5
--- /dev/null
+++ b/docs/public/favicon.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/scripts/validate-redirect-targets.mjs b/docs/scripts/validate-redirect-targets.mjs
new file mode 100644
index 00000000000..1ec6e7f0898
--- /dev/null
+++ b/docs/scripts/validate-redirect-targets.mjs
@@ -0,0 +1,65 @@
+import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
+import { dirname, join, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const docsRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
+const contentRoot = join(docsRoot, 'src', 'content', 'docs');
+const redirectsFile = join(docsRoot, 'src', 'config', 'redirects.ts');
+
+const normalizeRoute = (route) => {
+ const normalized = route
+ .replace(/^\/+|\/+$/g, '')
+ .split('/')
+ .filter(Boolean)
+ .map((segment) => segment.toLowerCase().replaceAll(' ', '-'))
+ .join('/');
+
+ return normalized ? `/${normalized}` : '/';
+};
+
+const collectDocsRoutes = (dir, routes = new Set()) => {
+ for (const entry of readdirSync(dir)) {
+ const entryPath = join(dir, entry);
+ const stats = statSync(entryPath);
+
+ if (stats.isDirectory()) {
+ collectDocsRoutes(entryPath, routes);
+ continue;
+ }
+
+ if (!entry.endsWith('.md') && !entry.endsWith('.mdx')) {
+ continue;
+ }
+
+ const relativePath = relative(contentRoot, entryPath).replace(/\\/g, '/').replace(/\.mdx?$/, '');
+ const route = relativePath.endsWith('/index') ? relativePath.slice(0, -'/index'.length) : relativePath;
+ routes.add(normalizeRoute(route));
+
+ const segments = route.split('/').filter(Boolean);
+ for (let index = 1; index < segments.length; index++) {
+ routes.add(normalizeRoute(segments.slice(0, index).join('/')));
+ }
+ }
+
+ return routes;
+};
+
+if (!existsSync(contentRoot)) {
+ throw new Error(`Docs content directory not found: ${contentRoot}`);
+}
+
+const redirectsSource = readFileSync(redirectsFile, 'utf8');
+const redirectMatches = redirectsSource.matchAll(/^\s*['"]([^'"]+)['"]:\s*['"]([^'"]+)['"]/gm);
+const redirectTargets = Array.from(redirectMatches, ([, from, to]) => ({ from, to }));
+const docsRoutes = collectDocsRoutes(contentRoot);
+const missingTargets = redirectTargets.filter(({ to }) => !docsRoutes.has(normalizeRoute(to)));
+
+if (missingTargets.length > 0) {
+ console.error('Redirect targets must resolve to generated docs routes:');
+ for (const { from, to } of missingTargets) {
+ console.error(` ${from} -> ${to}`);
+ }
+ process.exit(1);
+}
+
+console.log(`Validated ${redirectTargets.length} redirect targets.`);
diff --git a/docs/scripts/verify-deploy-output.mjs b/docs/scripts/verify-deploy-output.mjs
new file mode 100644
index 00000000000..885d1f44baa
--- /dev/null
+++ b/docs/scripts/verify-deploy-output.mjs
@@ -0,0 +1,70 @@
+import { readFileSync } from 'node:fs';
+
+const deployTarget = process.env.DEPLOY_TARGET ?? 'custom';
+const base = deployTarget === 'ghpages' ? '/InvokeAI' : '';
+const withBase = (path) => `${base}${path}`;
+
+const expectations = [
+ {
+ file: 'index.html',
+ includes: [
+ `href="${withBase('/_astro/')}`,
+ `src="${withBase('/_astro/')}`,
+ `href="${withBase('/start-here/installation/')}`,
+ ],
+ excludes: deployTarget === 'custom' ? ['href="/InvokeAI/', 'src="/InvokeAI/'] : ['href="/_astro/', 'src="/_astro/'],
+ },
+ {
+ file: 'contributing/index.html',
+ includes: [`href="${withBase('/contributing/new-contributor-guide/')}`],
+ excludes: [
+ deployTarget === 'custom'
+ ? 'href="/InvokeAI/contributing/new-contributor-guide/"'
+ : 'href="/contributing/new-contributor-guide/"',
+ 'newContributorChecklist.md',
+ ],
+ },
+ {
+ file: 'contributing/contribution_guides/newContributorChecklist/index.html',
+ includes: [
+ `Redirecting to: ${withBase('/contributing/new-contributor-guide')}`,
+ `content="0;url=${withBase('/contributing/new-contributor-guide')}`,
+ `href="${withBase('/contributing/new-contributor-guide')}`,
+ ],
+ excludes: deployTarget === 'custom'
+ ? [
+ 'Redirecting to: /InvokeAI/contributing/new-contributor-guide',
+ 'content="0;url=/InvokeAI/contributing/new-contributor-guide',
+ 'href="/InvokeAI/contributing/new-contributor-guide',
+ ]
+ : [
+ 'Redirecting to: /contributing/new-contributor-guide',
+ 'content="0;url=/contributing/new-contributor-guide',
+ 'href="/contributing/new-contributor-guide',
+ ],
+ },
+];
+
+const errors = [];
+
+for (const { file, includes = [], excludes = [] } of expectations) {
+ const html = readFileSync(new URL(`../dist/${file}`, import.meta.url), 'utf8');
+
+ for (const expected of includes) {
+ if (!html.includes(expected)) {
+ errors.push(`${file} is missing ${expected}`);
+ }
+ }
+
+ for (const unexpected of excludes) {
+ if (html.includes(unexpected)) {
+ errors.push(`${file} still contains ${unexpected}`);
+ }
+ }
+}
+
+if (errors.length > 0) {
+ throw new Error(`${deployTarget} output validation failed:\n- ${errors.join('\n- ')}`);
+}
+
+console.log(`${deployTarget} output links and assets look correct.`);
diff --git a/docs/src/assets/coverimage.png b/docs/src/assets/coverimage.png
new file mode 100644
index 00000000000..6586d8ba738
Binary files /dev/null and b/docs/src/assets/coverimage.png differ
diff --git a/docs/src/assets/invoke-icon-wide.svg b/docs/src/assets/invoke-icon-wide.svg
new file mode 100644
index 00000000000..cfeff994147
--- /dev/null
+++ b/docs/src/assets/invoke-icon-wide.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/assets/invoke-icon.svg b/docs/src/assets/invoke-icon.svg
new file mode 100644
index 00000000000..17cfdc77da7
--- /dev/null
+++ b/docs/src/assets/invoke-icon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docs/src/config/head.ts b/docs/src/config/head.ts
new file mode 100644
index 00000000000..03fe1debd5f
--- /dev/null
+++ b/docs/src/config/head.ts
@@ -0,0 +1,79 @@
+import type { StarlightUserConfig } from '@astrojs/starlight/types';
+
+type HeadConfig = NonNullable;
+
+type CreateHeadConfigParams = {
+ base: string;
+ enableAnalytics: boolean;
+ isGhPages: boolean;
+ site: string;
+};
+
+const plausibleScriptUrl =
+ 'https://plausible.tracking.events/js/pa-BHcumuOemKz4XIQeWkTn4.js';
+const plausibleInitScript =
+ 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()';
+
+function createHeadConfig({
+ base,
+ enableAnalytics,
+ isGhPages,
+ site,
+}: CreateHeadConfigParams): HeadConfig {
+ const coverImageUrl = new URL(`${base}/coverimage.png`, site).toString();
+
+ return [
+ {
+ tag: 'meta',
+ attrs: {
+ property: 'og:image',
+ content: coverImageUrl,
+ },
+ },
+ {
+ tag: 'meta',
+ attrs: {
+ property: 'og:image:width',
+ content: '1200',
+ },
+ },
+ {
+ tag: 'meta',
+ attrs: {
+ property: 'og:image:height',
+ content: '630',
+ },
+ },
+ {
+ tag: 'meta',
+ attrs: {
+ name: 'twitter:card',
+ content: 'summary_large_image',
+ },
+ },
+ {
+ tag: 'meta',
+ attrs: {
+ name: 'twitter:image',
+ content: coverImageUrl,
+ },
+ },
+ ...(enableAnalytics && !isGhPages
+ ? ([
+ {
+ tag: 'script',
+ attrs: {
+ async: true,
+ src: plausibleScriptUrl,
+ },
+ },
+ {
+ tag: 'script',
+ content: plausibleInitScript,
+ },
+ ] satisfies HeadConfig)
+ : []),
+ ] satisfies HeadConfig;
+}
+
+export { createHeadConfig };
diff --git a/docs/src/config/index.ts b/docs/src/config/index.ts
new file mode 100644
index 00000000000..6fdc64bfb21
--- /dev/null
+++ b/docs/src/config/index.ts
@@ -0,0 +1,4 @@
+export * from './head';
+export * from './redirects';
+export * from './sidebar';
+export * from './social';
diff --git a/docs/src/config/redirects.ts b/docs/src/config/redirects.ts
new file mode 100644
index 00000000000..8c2b3e69c7f
--- /dev/null
+++ b/docs/src/config/redirects.ts
@@ -0,0 +1,55 @@
+import type { AstroConfig } from 'astro';
+
+type RedirectsConfig = AstroConfig['redirects'];
+
+const redirects: RedirectsConfig = {
+ '/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/',
+ '/features/lasso-tool': '/features/canvas/lasso-tool',
+ '/features/shapes-tool': '/features/canvas/shapes-tool',
+ '/faq': '/troubleshooting/faq',
+ '/help/SAMPLER_CONVERGENCE': '/concepts/parameters',
+ '/help/diffusion': '/concepts/diffusion',
+ '/help/gettingStartedWithAI': '/concepts/image-generation',
+ '/nodes/NODES': '/features/workflows/editor-interface',
+ '/nodes/NODES_MIGRATION_V3_V4': '/development/guides/api-development',
+ '/nodes/comfyToInvoke': '/features/workflows/comfyui-migration',
+ '/nodes/communityNodes': '/features/workflows/community-nodes',
+ '/nodes/contributingNodes': '/development/guides/creating-nodes',
+ '/nodes/detailedNodes/faceTools': '/features/workflows/face-tools',
+ '/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',
+};
+
+function createRedirects(base: string): RedirectsConfig {
+ return Object.fromEntries(
+ Object.entries(redirects).map(([from, to]) => [from, base + to]),
+ );
+}
+
+export { createRedirects };
diff --git a/docs/src/config/sidebar.ts b/docs/src/config/sidebar.ts
new file mode 100644
index 00000000000..7d85c5d2e1d
--- /dev/null
+++ b/docs/src/config/sidebar.ts
@@ -0,0 +1,80 @@
+import type { StarlightUserConfig } from '@astrojs/starlight/types';
+import { makeChangelogsSidebarLinks } from 'starlight-changelogs';
+
+type SidebarConfig = StarlightUserConfig['sidebar'];
+
+const sidebar: SidebarConfig = [
+ {
+ label: 'Start Here',
+ items: [
+ {
+ autogenerate: { directory: 'start-here' },
+ },
+ ],
+ },
+ {
+ label: 'Configuration',
+ items: [
+ {
+ autogenerate: { directory: 'configuration' },
+ },
+ ],
+ },
+ {
+ label: 'Concepts',
+ items: [
+ {
+ autogenerate: { directory: 'concepts' },
+ },
+ ],
+ },
+ {
+ label: 'Features',
+ items: [
+ {
+ autogenerate: { directory: 'features' },
+ },
+ ],
+ },
+ {
+ label: 'Development',
+ items: [
+ {
+ autogenerate: { directory: 'development', collapsed: true },
+ },
+ ],
+ collapsed: true,
+ },
+ {
+ label: 'Contributing',
+ items: [
+ {
+ autogenerate: { directory: 'contributing' },
+ },
+ ],
+ collapsed: true,
+ },
+ {
+ label: 'Troubleshooting',
+ items: [
+ {
+ autogenerate: { directory: 'troubleshooting' },
+ },
+ ],
+ collapsed: true,
+ },
+ {
+ label: 'Releases',
+ collapsed: true,
+ items: [
+ ...makeChangelogsSidebarLinks([
+ {
+ type: 'recent',
+ base: 'releases',
+ },
+ ]),
+ ],
+ },
+];
+
+export { sidebar as sidebarConfig };
diff --git a/docs/src/config/social.ts b/docs/src/config/social.ts
new file mode 100644
index 00000000000..8c7d416a004
--- /dev/null
+++ b/docs/src/config/social.ts
@@ -0,0 +1,23 @@
+import type { StarlightUserConfig } from '@astrojs/starlight/types';
+
+type SocialConfig = StarlightUserConfig['social'];
+
+const social: SocialConfig = [
+ {
+ icon: 'github',
+ label: 'GitHub',
+ href: 'https://github.com/invoke-ai/InvokeAI',
+ },
+ {
+ icon: 'discord',
+ label: 'Discord',
+ href: 'https://discord.gg/ZmtBAhwWhy',
+ },
+ {
+ icon: 'youtube',
+ label: 'YouTube',
+ href: 'https://www.youtube.com/@invokeai',
+ },
+];
+
+export { social as socialConfig };
diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts
new file mode 100644
index 00000000000..5dbc874545d
--- /dev/null
+++ b/docs/src/content.config.ts
@@ -0,0 +1,22 @@
+import { defineCollection } from 'astro:content';
+import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders';
+import { docsSchema, i18nSchema } from '@astrojs/starlight/schema';
+
+import { changelogsLoader } from 'starlight-changelogs/loader';
+
+export const collections = {
+ docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+ i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }),
+ changelogs: defineCollection({
+ loader: changelogsLoader([
+ {
+ title: "Releases",
+ provider: 'github',
+ base: 'releases',
+ owner: 'invoke-ai',
+ repo: 'InvokeAI',
+ pagefind: false,
+ }
+ ]),
+ })
+};
diff --git a/docs/src/content/docs/assets/controlnets-parallax/city-canny.png b/docs/src/content/docs/assets/controlnets-parallax/city-canny.png
new file mode 100644
index 00000000000..f46ad56e1b5
Binary files /dev/null and b/docs/src/content/docs/assets/controlnets-parallax/city-canny.png differ
diff --git a/docs/src/content/docs/assets/controlnets-parallax/city-depth.png b/docs/src/content/docs/assets/controlnets-parallax/city-depth.png
new file mode 100644
index 00000000000..5ed305e8cd9
Binary files /dev/null and b/docs/src/content/docs/assets/controlnets-parallax/city-depth.png differ
diff --git a/docs/src/content/docs/assets/controlnets-parallax/city-og.png b/docs/src/content/docs/assets/controlnets-parallax/city-og.png
new file mode 100644
index 00000000000..25fd75cdf6e
Binary files /dev/null and b/docs/src/content/docs/assets/controlnets-parallax/city-og.png differ
diff --git a/docs/src/content/docs/assets/controlnets-parallax/city-ui-layers.png b/docs/src/content/docs/assets/controlnets-parallax/city-ui-layers.png
new file mode 100644
index 00000000000..6ddc0f74058
Binary files /dev/null and b/docs/src/content/docs/assets/controlnets-parallax/city-ui-layers.png differ
diff --git a/docs/assets/invoke-web-server-1.png b/docs/src/content/docs/assets/invoke-web-server-1.png
similarity index 100%
rename from docs/assets/invoke-web-server-1.png
rename to docs/src/content/docs/assets/invoke-web-server-1.png
diff --git a/docs/src/content/docs/assets/invoke-webui-canvas.png b/docs/src/content/docs/assets/invoke-webui-canvas.png
new file mode 100644
index 00000000000..29dda3baf42
Binary files /dev/null and b/docs/src/content/docs/assets/invoke-webui-canvas.png differ
diff --git a/docs/assets/invoke_ai_banner.png b/docs/src/content/docs/assets/splash-banner.png
similarity index 100%
rename from docs/assets/invoke_ai_banner.png
rename to docs/src/content/docs/assets/splash-banner.png
diff --git a/docs/src/content/docs/concepts/diffusion.mdx b/docs/src/content/docs/concepts/diffusion.mdx
new file mode 100644
index 00000000000..27030b2af4a
--- /dev/null
+++ b/docs/src/content/docs/concepts/diffusion.mdx
@@ -0,0 +1,77 @@
+---
+title: Diffusion
+lastUpdated: 2026-02-20
+sidebar:
+ order: 5
+---
+
+import { Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Taking the time to understand the diffusion process will help you to understand how to more effectively use InvokeAI.
+
+## Image Space vs. Latent Space
+
+There are two main ways Stable Diffusion works — with images, and latents.
+
+
+
+ Represents images in pixel form that you look at. This is the final visual output you see.
+
+
+ Represents compressed inputs. It's in latent space that Stable Diffusion processes images.
+
+
+
+:::note[What is a VAE?]
+ A **VAE (Variational Auto Encoder)** is responsible for compressing and encoding inputs into *latent space*, as well as decoding outputs back into *image space*.
+:::
+
+## Core Components
+
+To fully understand the diffusion process, we need to understand a few more terms: **U-Net**, **CLIP**, and **conditioning**.
+
+
+
+ A model trained on a large number of latent images with known amounts of random noise added. The U-Net can be given a slightly noisy image and it will predict the pattern of noise needed to subtract from the image in order to recover the original.
+
+
+ **CLIP** is a model that tokenizes and encodes text into **conditioning**. This conditioning guides the model during the denoising steps to produce a new image.
+
+
+
+The U-Net and CLIP work together during the image generation process at each denoising step. The U-Net removes noise so that the result is similar to images in its training set, while CLIP guides the U-Net towards creating images that are most similar to your prompt.
+
+## The Generation Process
+
+
+
+ When you generate an image using text-to-image, multiple steps occur in latent space:
+
+
+ 1. **Noise Generation:** Random noise is generated at the chosen height and width. The noise's characteristics are dictated by the seed. This noise tensor is passed into latent space. We'll call this *noise A*.
+ 2. **Noise Prediction:** Using a model's U-Net, a noise predictor examines *noise A* and the words tokenized by CLIP from your prompt (conditioning). It generates its own noise tensor to predict what the final image might look like in latent space. We'll call this *noise B*.
+ 3. **Subtraction:** *Noise B* is subtracted from *noise A* in an attempt to create a latent image consistent with the prompt. This step is repeated for the number of sampler steps chosen.
+ 4. **Decoding:** The VAE decodes the final latent image from latent space into image space.
+
+
+
+ Image-to-image is a similar process, with only the first step being different:
+
+
+ 1. **Encoding & Adding Noise:** The input image is encoded from image space into latent space by the VAE. Noise is then added to the input latent image.
+ * **Denoising Strength** dictates how many noise steps are added, and the amount of noise added at each step.
+ * A strength of `0` means there are 0 steps and no noise added, resulting in an unchanged image.
+ * A strength of `1` results in the image being completely replaced with noise and a full set of denoising steps are performed.
+ 2. **Noise Prediction:** Using a model's U-Net, a noise predictor examines the noisy latent image and the conditioning from your prompt. It generates its own noise tensor to predict the final image.
+ 3. **Subtraction:** The predicted noise is subtracted from the current noise in an attempt to create a latent image consistent with the prompt. This step is repeated for the remaining sampler steps.
+ 4. **Decoding:** The VAE decodes the final latent image from latent space into image space.
+
+
+
+
+## Summary
+
+
+- A **Model** provides the CLIP prompt tokenizer, the VAE, and a U-Net (where noise prediction occurs given a prompt and initial noise tensor).
+- A **Noise Scheduler** (e.g. `DPM++ 2M Karras`) schedules the subtraction of noise from the latent image across the sampler steps chosen. Less noise is usually subtracted at higher sampler steps.
+
diff --git a/docs/src/content/docs/concepts/dynamic-prompting.mdx b/docs/src/content/docs/concepts/dynamic-prompting.mdx
new file mode 100644
index 00000000000..c345fea5fe4
--- /dev/null
+++ b/docs/src/content/docs/concepts/dynamic-prompting.mdx
@@ -0,0 +1,133 @@
+---
+title: Dynamic Prompting
+lastUpdated: 2026-03-30
+sidebar:
+ order: 4
+---
+
+import { Card, CardGrid, Steps, LinkCard } from '@astrojs/starlight/components';
+
+Dynamic prompting expands a single prompt into many prompt variations. It is useful for brainstorming, prompt exploration, and batch testing without rewriting the same prompt by hand.
+
+## Basic syntax
+
+Put alternatives inside braces and separate them with `|`.
+
+```text
+a {red|green|blue} balloon
+```
+
+This can expand into:
+
+```text
+a red balloon
+a green balloon
+a blue balloon
+```
+
+You can use more than one dynamic group in the same prompt:
+
+```text
+a {red|green} {balloon|kite}
+```
+
+That creates a set of prompt combinations such as `a red balloon`, `a red kite`, `a green balloon`, and `a green kite`.
+
+## Select more than one option with `$$`
+
+Prefix a group with a number and `$$` to choose multiple distinct options from the same set.
+
+```text
+portrait, {2$$rim light|fog|rain|neon reflections}
+```
+
+Possible results include:
+
+```text
+portrait, rim light, fog
+portrait, fog, rain
+portrait, rim light, neon reflections
+```
+
+This is useful when you want controlled variety without writing every combination by hand.
+
+## Random vs combinatorial expansion
+
+
+
+ Walks the possible prompt combinations systematically until `Max Prompts` is reached.
+
+
+ Samples prompt variations instead of enumerating every combination. A seed can make random expansion repeatable.
+
+
+
+InvokeAI supports both modes, but where you can choose them depends on the workflow.
+
+- In the current linear UI, dynamic prompt preview is driven from the positive prompt and currently follows the standard combinatorial expansion path.
+- In node and backend contexts, random and combinatorial generation are exposed more explicitly.
+
+## Max Prompts
+
+`Max Prompts` limits how many expanded prompts InvokeAI will generate.
+
+This matters because combinations grow quickly. For example:
+
+```text
+a {red|green|blue} balloon in {morning mist|golden hour|rain}
+```
+
+Even this small prompt already has nine possible combinations.
+
+:::tip[Start small]
+ Preview a handful of prompt variants first. Once the combinations look useful, increase `Max Prompts` for a larger batch.
+:::
+
+## Seed Behaviour
+
+In the current UI, the `Seed Behaviour` setting controls how seeds are reused across expanded prompts.
+
+
+
+ Uses one seed per iteration, so prompt variants in the same iteration share a seed. This is useful when you want to compare prompt wording more directly.
+
+
+ Uses a different seed for every generated image. This is useful when you want the widest possible variety.
+
+
+
+## Using dynamic prompting in the linear UI
+
+
+ 1. **Put dynamic prompt syntax in the positive prompt**
+
+ In the current linear UI, dynamic prompt expansion is driven from the positive prompt.
+
+ 2. **Open the preview**
+
+ Use `Show Dynamic Prompts` or the prompts preview to inspect the expanded list before you generate.
+
+ 3. **Set `Max Prompts`**
+
+ Keep the expansion under control before launching a large batch.
+
+ 4. **Choose the right seed behavior**
+
+ Use `Seed per Iteration` for easier comparison, or `Seed per Image` for more variety.
+
+ 5. **Generate a small batch first**
+
+ Sanity-check the combinations before scaling up.
+
+
+:::note[Current linear UI behavior]
+ The linear UI currently exposes `Max Prompts`, preview, and seed behavior. It does not expose a separate random-versus-combinatorial mode switch in the main positive prompt flow.
+:::
+
+## Tips
+
+- Keep each option group internally compatible.
+- 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](../prompt-syntax) instead of adding more dynamic groups.
diff --git a/docs/src/content/docs/concepts/image-generation.mdx b/docs/src/content/docs/concepts/image-generation.mdx
new file mode 100644
index 00000000000..6aa25f4b34c
--- /dev/null
+++ b/docs/src/content/docs/concepts/image-generation.mdx
@@ -0,0 +1,153 @@
+---
+title: Image Generation
+lastUpdated: 2026-03-30
+sidebar:
+ order: 1
+---
+
+import { Card, CardGrid, Steps, LinkCard } from '@astrojs/starlight/components';
+
+:::tip[New to image generation with AI?]
+ You're in the right place! This is a high-level walkthrough of some of the concepts and terms you'll see as you start using Invoke. Please note, this is not an exhaustive guide and may be out of date due to the rapidly changing nature of the space.
+:::
+
+## Using InvokeAI
+
+### Prompt Crafting
+
+Prompts are the basis of using InvokeAI, providing the models directions on what to generate. As a general rule of thumb, the more detailed your prompt is, the better your result will be.
+
+
+ To get started, here's an easy template to use for structuring your prompts:
+ **Subject, Style, Quality, Aesthetic**
+
+ - **Subject:** What your image will be about. E.g. “a futuristic city with trains”, “penguins floating on icebergs”, “friends sharing beers”.
+ - **Style:** The style or medium in which your image will be in. E.g. “photograph”, “pencil sketch”, “oil paints”, or “pop art”, “cubism”, “abstract”.
+ - **Quality:** A particular aspect or trait that you would like to see emphasized in your image. E.g. "award-winning", "featured in relevant set of high quality works", "professionally acclaimed". Many people often use "masterpiece".
+ - **Aesthetics:** The visual impact and design of the artwork. This can be colors, mood, lighting, setting, etc.
+
+
+There are two prompt boxes: **Positive Prompt** & **Negative Prompt**.
+
+- A **Positive Prompt** includes words you want the model to reference when creating an image.
+- A **Negative Prompt** is for anything you want the model to eliminate when creating an image. It doesn’t always interpret things exactly the way you would, but helps control the generation process. Always try to include a few terms - you can typically use lower quality image terms like “blurry” or “distorted” with good success.
+
+**Some example prompts you can try on your own:**
+
+- *A detailed oil painting of a tranquil forest at sunset with vibrant colors and soft, golden light filtering through the trees*
+- *friends sharing beers in a busy city, realistic colored pencil sketch, twilight, masterpiece, bright, lively*
+
+### Advanced Prompting
+
+
+
+
+
+
+
+### Generation Workflows
+
+Invoke offers a number of different workflows for interacting with models to produce images. Each is extremely powerful on its own, but together provide you an unparalleled way of producing high quality creative outputs that align with your vision.
+
+
+
+ Focuses on the key workflow of using a prompt to generate a new image. It includes other features that help control the generation process as well.
+
+
+ Provide an image as a reference (called the “initial image”), which provides more guidance around color and structure to the AI as it generates a new image.
+
+
+ An advanced AI-first image editing tool. Drag an image onto the canvas to regenerate elements, edit content or colors (**inpainting**), or extend the image with consistency and clarity (**outpainting**).
+
+
+
+### Improving Image Quality
+
+
+ 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](../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.*
+ :::
+
+ 2. **Explore different models:**
+
+ Other models can produce different results due to the data they've been trained on. Each model has specific language and settings it works best with; a model's documentation is your friend here. Play around with some and see what works best for you!
+
+ 3. **Increasing Steps:**
+
+ The number of steps used controls how much time the model is given to produce an image, and depends on the "Scheduler" used. More steps tends to mean better results, but will take longer. We recommend at least 30 steps for most.
+
+ 4. **Tweak and Iterate:**
+
+ Remember, it's best to change one thing at a time so you know what is working and what isn't. Sometimes you just need to try a new image, and other times using a new prompt might be the ticket.
+ *For testing, consider turning off the "random" Seed. Using the same seed with the same settings will produce the same image, which makes it the perfect way to learn exactly what your changes are doing.*
+
+ 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 [features docs](../../features/gallery) if you want to learn more.
+
+
+## Terms & Concepts
+
+:::note
+ If you're interested in learning more, check out [this presentation](https://docs.google.com/presentation/d/1IO78i8oEXFTZ5peuHHYkVF-Y3e2M6iM5tCnc-YBfcCM/edit?usp=sharing) from one of our maintainers (@lstein).
+:::
+
+### Stable Diffusion
+
+Stable Diffusion is a deep learning, text-to-image model that is the foundation of the capabilities found in InvokeAI. Since the release of Stable Diffusion, there have been many subsequent models created based on Stable Diffusion that are designed to generate specific types of images.
+
+### Prompts
+
+Prompts provide the models directions on what to generate. As a general rule of thumb, the more detailed your prompt is, the better your result will be.
+
+### Models
+
+Models are the magic that power InvokeAI. These files represent the output of training a machine on understanding massive amounts of images - providing them with the capability to generate new images using just a text description of what you'd like to see.
+
+Invoke offers a simple way to download several different models upon installation, but many more can be discovered online, including at [civitai.com](https://civitai.com). Each model can produce a unique style of output, based on the images it was trained on.
+
+:::note
+ Models that contain "inpainting" in the name are designed for use with the inpainting feature of the Unified Canvas.
+:::
+
+### Schedulers & Steps
+
+**Schedulers** guide the process of removing noise (de-noising) from data. They determine:
+1. The number of steps to take to remove the noise.
+2. Whether the steps are random (stochastic) or predictable (deterministic).
+3. The specific method (algorithm) used for de-noising.
+
+**Steps** represent the number of de-noising iterations each generation goes through. Schedulers can be intricate and there's often a balance to strike between how quickly they can de-noise data and how well they can do it. It's typically advised to experiment with different schedulers to see which one gives the best results.
+
+### Additional Concepts
+
+
+
+ LoRAs are like a smaller, more focused version of models, intended to focus on training a better understanding of how a specific character, style, or concept looks.
+
+
+ Like LoRAs, embeddings assist with more easily prompting for certain characters, styles, or concepts. They are trained to update the relationship between a specific word (known as the "trigger") and the intended output.
+
+
+ ControlNets are neural network models that are able to extract key features from an existing image and use these features to guide the output of the image generation model.
+
+
+ A Variational Auto-Encoder (VAE) is an encode/decode model that translates the "latents" image produced during the image generation process to the large pixel images that we see.
+
+
diff --git a/docs/src/content/docs/concepts/models.mdx b/docs/src/content/docs/concepts/models.mdx
new file mode 100644
index 00000000000..3ebdf27c788
--- /dev/null
+++ b/docs/src/content/docs/concepts/models.mdx
@@ -0,0 +1,133 @@
+---
+title: Models
+sidebar:
+ order: 8
+---
+
+## Checkpoint and Diffusers Models
+
+The model checkpoint files (`*.ckpt`) are the Stable Diffusion "secret sauce". They are the product of training the AI on millions of captioned images gathered from multiple sources.
+
+Originally there was only a single Stable Diffusion weights file, which many people named `model.ckpt`.
+
+Today, there are thousands of models, fine tuned to excel at specific styles, genres, or themes.
+
+:::tip[Model Formats]
+ We also have two more popular model formats, both created by [HuggingFace](https://huggingface.co/):
+
+ - `safetensors`: Single file, like `.ckpt` files. Prevents malware from lurking in a model.
+ - `diffusers`: Splits the model components into separate files, allowing very fast loading.
+
+ InvokeAI supports all three formats.
+:::
+
+## Starter Models
+
+When you first start InvokeAI, you'll see a popup prompting you to install some starter models from the Model Manager. Click the `Starter Models` tab to see the list.
+
+You'll find a collection of popular and high-quality models available for easy download.
+
+Some models carry license terms that limit their use in commercial applications or on public servers. It's your responsibility to adhere to the license terms.
+
+## Other Models
+
+There are a few ways to install other models:
+
+- **URL or Local Path**: Provide the path to a model on your computer, or a direct link to the model. Some sites require you to use an API token to download models, which you can [set up in the config file]. You can also paste a HuggingFace Repo ID here directly — it is detected and routed to the HuggingFace installer automatically.
+- **HuggingFace**: Paste a HF Repo ID to install it. If there are multiple models in the repo, you'll get a list to choose from. Repo IDs look like this: `XpucT/Deliberate`. There is a copy button on each repo to copy the ID.
+- **Scan Folder**: Scan a local folder for models. You can install all of the detected models in one click.
+
+### Diffusers models in HF repo subfolders
+
+HuggingFace repos can be structured in any way. Some model authors include multiple models within the same folder.
+
+In this situation, you may need to provide some additional information to identify the model you want, by adding `:subfolder_name` to the repo ID.
+
+:::note[Example]
+ Say you have a repo ID `monster-labs/control_v1p_sd15_qrcode_monster`, and the model you want is inside the `v2` subfolder.
+
+ 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
+
+## Editing model metadata
+
+Every model has an editable **Source URL** field alongside its name and description. Use it to record where a model came from — for example a Civitai or HuggingFace page — independent of how it was originally installed. The URL is editable from the model's **Edit** view and appears as a clickable link in the model header once set. Models without a URL simply hide the field.
+
+This is purely metadata: the URL has no effect on loading and is not used to refresh or reinstall the model. It is mainly useful for going back to the model's documentation, license, or example prompts later.
+
+## Bulk actions in the Model Manager
+
+The Model Manager supports multi-selection for batch operations.
+
+- **Select multiple models** by clicking with **Ctrl** (Windows / Linux) or **Cmd** (macOS) held, or by using the checkboxes on each row. A sticky header at the top shows the current selection count and is always visible while you scroll.
+- Open the **Actions** dropdown for the selection. The available actions are:
+ - **Delete Models** — removes every selected model in a single confirmation step. Partial failures (e.g. permission issues) are reported per-model in the result toast.
+ - **Reidentify Models** — re-probes every selected model, updating fields that depend on the file contents (type, base, format, variant, etc.). This is the bulk version of the per-model reidentify action.
+
+:::caution[Reidentify resets custom settings]
+Reidentifying a model re-derives its configuration from the file on disk. Any custom settings you've adjusted on those models — default settings, descriptions, trigger phrases — may be overwritten. The confirmation modal warns you about this before running.
+:::
+
+Both actions handle partial failures: if some models succeed and others fail, the toast lists succeeded and failed counts and the list view updates immediately for the ones that worked.
+
+## Finding orphaned models
+
+If a model file is deleted or moved outside the Model Manager, its database entry sticks around. To find these orphaned entries:
+
+1. Open the Model Manager.
+2. Open the **type filter** dropdown and pick **Missing Files**.
+3. The list now shows only models whose files are no longer present on disk. Each one also displays a **Missing Files** badge in its row.
+
+Orphaned models are automatically excluded from selection dropdowns (main model, LoRA, VAE, etc.), so you cannot accidentally pick one for generation. Use the [bulk delete action](#bulk-actions-in-the-model-manager) to clean them out in one step.
+
+## Synchronizing orphaned model directories
+
+The **Missing Files** filter finds database records whose files are gone. InvokeAI also has a separate sync workflow for the opposite situation: model directories that still exist on disk but are not referenced in the database.
+
+This can happen after a failed import, a manual database edit, or deleting a model record while leaving files behind. The sync workflow scans the models directory for top-level folders containing model files with common model extensions, including `.safetensors`, `.ckpt`, `.pt`, `.pth`, `.bin`, `.onnx`, and `.gguf`.
+
+To review these directories:
+
+1. In multi-user mode, sign in as an administrator. In single-user mode, the Model Manager controls are available by default.
+2. Open the Model Manager.
+3. Click **Sync Models** to scan for orphaned model directories.
+4. Review each reported relative directory path, contained model files, and total size before deleting anything.
+
+:::caution[Deletion removes directories]
+Deleting an orphaned model directory removes the entire reported directory from disk. The server deletes it directly with recursive directory deletion, so make sure the directory contains only files you intend to remove.
+:::
+
+Only administrators can use this workflow in multi-user mode. The underlying API is `/api/v2/models/sync/orphaned`; API results also include the absolute path for each reported directory.
+
+## Exporting and Importing Model Settings
+
+Each installed model has an **Export Settings** and **Import Settings** action in the Model Manager. Use these to back up a model's configuration, move it to another install, or share a curated setup with someone else.
+
+### What gets exported
+
+The exported `.json` file captures the configuration you have set on the model, not the model weights themselves:
+
+- `default_settings` — steps, CFG / guidance, scheduler, dimensions, FP8 storage toggle, VAE precision, etc.
+- `trigger_phrases` — for LoRAs and similar.
+- `cpu_only` — for encoder-type models.
+- `name`, `description`, `source_url` — the model's identifying metadata.
+- `cover_image` — the model's thumbnail, embedded as a base64 data URL.
+
+Fields you have not set are omitted from the file. The format is forward and backward compatible: older clients ignore newer fields, and a file produced by a newer version still imports cleanly into an older one (it just skips the fields it does not understand).
+
+### Importing
+
+Importing applies the JSON to the currently selected model:
+
+- `default_settings`, `trigger_phrases`, `cpu_only`, `name`, `description`, and `source_url` are applied via the normal model update path. Any field that the target model type does not support (e.g. `cpu_only` on a model that has no such setting) is listed in a "skipped" toast — everything else still applies.
+- `cover_image` is uploaded and set as the model's thumbnail.
+
+Imports are validated before they run. The file is rejected if `source_url` is not an `http(s)://` URL or if `cover_image` is not a valid image data URL — so a malformed or hand-edited file cannot quietly poison a model's configuration.
+
+### Typical workflows
+
+- **Back up a model you've spent time tuning** so you can restore its settings after a reinstall, or roll back after experimenting.
+- **Copy settings between two installs of the same model** — e.g. between a desktop and a workstation.
+- **Share a curated setup** (name, description, thumbnail, default steps / CFG / scheduler, trigger phrases) for a model you have configured well.
diff --git a/docs/src/content/docs/concepts/nodes-workflows.mdx b/docs/src/content/docs/concepts/nodes-workflows.mdx
new file mode 100644
index 00000000000..019d999bb35
--- /dev/null
+++ b/docs/src/content/docs/concepts/nodes-workflows.mdx
@@ -0,0 +1,29 @@
+---
+title: Nodes and Workflows
+sidebar:
+ order: 7
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+## What are Nodes?
+
+A **Node** is simply a single operation that takes in inputs and returns outputs. Multiple nodes can be linked together to create more complex functionality. All InvokeAI features are added through nodes.
+
+With nodes, you can easily extend the image generation capabilities of InvokeAI and build workflows that suit your specific needs.
+
+### Anatomy of a Node
+
+Individual nodes are made up of the following:
+
+
+
+ Edge points on the **left side** of the node window where you connect outputs from other nodes.
+
+
+ Edge points on the **right side** of the node window where you connect to inputs on other nodes.
+
+
+ Various options which are either manually configured, or overridden by connecting an output from another node to the input.
+
+
diff --git a/docs/src/content/docs/concepts/parameters.mdx b/docs/src/content/docs/concepts/parameters.mdx
new file mode 100644
index 00000000000..c4882de919e
--- /dev/null
+++ b/docs/src/content/docs/concepts/parameters.mdx
@@ -0,0 +1,143 @@
+---
+title: Generation Parameters
+lastUpdated: 2026-02-20
+sidebar:
+ order: 6
+---
+
+import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
+
+# Sampler Convergence
+
+As features keep increasing, making the right choices for your needs can become increasingly difficult. What sampler to use? And for how many steps? Do you change the CFG value? Do you use prompt weighting? Do you allow variations?
+
+Even once you have a result, do you blend it with other images? Pass it through `img2img`? With what strength? Do you use inpainting to correct small details? Outpainting to extend cropped sections?
+
+The purpose of this series of documents is to help you better understand these tools, so you can make the best out of them. Feel free to contribute with your own findings!
+
+In this document, we will talk about **sampler convergence**.
+
+
+ Looking for a short version? Here is the summary:
+
+ - Results converge as steps (`-s`) are increased (except for `K_DPM_2_A` and `K_EULER_A`). Often at ≥ `-s100`, but may require ≥ `-s700`.
+ - Producing a batch of candidate images at low (`-s8` to `-s30`) step counts can save you hours of computation.
+ - `K_HEUN` and `K_DPM_2` converge in fewer steps (but are slower per step).
+ - `K_DPM_2_A` and `K_EULER_A` incorporate a lot of creativity and variability.
+
+
+## Sampler Performance Overview
+
+
+
+ *(Tested on M1 Max 64GB, 512x512, 3 sample average)*
+
+ | Sampler | it/s |
+ | :--- | :--- |
+ | `DDIM` | 1.89 |
+ | `PLMS` | 1.86 |
+ | `K_EULER` | 1.86 |
+ | `K_LMS` | **1.91** (Fastest) |
+ | `K_EULER_A` | 1.86 |
+ | `K_HEUN` | 0.95 *(Slower)* |
+ | `K_DPM_2` | 0.95 *(Slower)* |
+ | `K_DPM_2_A` | 0.95 *(Slower)* |
+
+
+
+ For most use cases, `K_LMS`, `K_HEUN` and `K_DPM_2` are the best choices.
+
+ While `K_HEUN` and `K_DPM_2` run half as fast, they tend to converge twice as quickly as `K_LMS`.
+
+ At very low steps (≤ `-s8`), `K_HEUN` and `K_DPM_2` are not recommended. Use `K_LMS` instead.
+
+ For high variability between steps, use `K_EULER_A` (which runs twice as fast as `K_DPM_2_A`).
+
+
+
+---
+
+## Sampler Results by Subject
+
+Let's start by choosing a prompt and using it with each of our 8 samplers, running it for 10, 20, 30, 40, 50 and 100 steps.
+
+### Anime
+> `"an anime girl" -W512 -H512 -C7.5 -S3031912972`
+
+
+
+Immediately, you can notice results tend to converge — that is, as `-s` (step) values increase, images look more and more similar until there comes a point where the image no longer changes.
+
+You can also notice how `DDIM` and `PLMS` eventually tend to converge to K-sampler results as steps are increased. Among K-samplers, `K_HEUN` and `K_DPM_2` seem to require the fewest steps to converge, and even at low step counts they are good indicators of the final result. Finally, `K_DPM_2_A` and `K_EULER_A` seem to do a bit of their own thing and don't keep much similarity with the rest of the samplers.
+
+### Nature
+Now, these results seem interesting, but do they hold for other topics? Let's try!
+
+> `"valley landscape wallpaper, d&d art, fantasy, painted, 4k, high detail, sharp focus, washed colors, elaborate excellent painted illustration" -W512 -H512 -C7.5 -S1458228930`
+
+
+
+With nature, you can see how initial results are even more indicative of the final result — more so than with characters/people. `K_HEUN` and `K_DPM_2` are again the quickest indicators, almost right from the start. Results also converge faster (e.g. `K_HEUN` converged at `-s21`).
+
+### Food
+> `"a hamburger with a bowl of french fries" -W512 -H512 -C7.5 -S4053222918`
+
+
+
+Again, `K_HEUN` and `K_DPM_2` take the fewest number of steps to be good indicators of the final result. `K_DPM_2_A` and `K_EULER_A` seem to incorporate a lot of creativity/variability, capable of producing rotten hamburgers, but also of adding lettuce to the mix. And they're the only samplers that produced an actual 'bowl of fries'!
+
+### Animals
+> `"grown tiger, full body" -W512 -H512 -C7.5 -S3721629802`
+
+
+
+`K_HEUN` and `K_DPM_2` once again require the least number of steps to be indicative of the final result (around `-s30`), while other samplers are still struggling with several tails or malformed back legs.
+
+It also takes longer to converge (for comparison, `K_HEUN` required around 150 steps to converge). This is normal, as producing human/animal faces/bodies is one of the things the model struggles the most with. For these topics, running for more steps will often increase coherence within the composition.
+
+### People
+> `"Ultra realistic photo, (Miranda Bloom-Kerr), young, stunning model, blue eyes, blond hair, beautiful face, intricate, highly detailed, smooth, art by artgerm and greg rutkowski and alphonse mucha, stained glass" -W512 -H512 -C7.5 -S2131956332`. *(This time, we will go up to 300 steps).*
+
+
+
+Observing the results, it again takes longer for all samplers to converge (`K_HEUN` took around 150 steps), but we can observe good indicative results much earlier (see: `K_HEUN`). Conversely, `DDIM` and `PLMS` are still undergoing moderate changes (see: lace around her neck), even at `-s300`.
+
+In fact, as we can see in this other experiment, some samplers can take 700+ steps to converge when generating people.
+
+
+
+Note also the point of convergence may not be the most desirable state (e.g. you might prefer an earlier version of the face that is more rounded), but it will probably be the most coherent regarding arms/hands/face attributes. You can always merge different images with a photo editing tool and pass it through `img2img` to smoothen the composition.
+
+---
+
+## Batch Generation Speedup
+
+This realization about convergence is very useful because it means you don't need to create a batch of 100 images (`-n100`) at `-s100` just to choose your favorite 2 or 3 images.
+
+You can produce the same 100 images at `-s10` to `-s30` using a K-sampler (since they converge faster), get a rough idea of the final result, choose your 2 or 3 favorite ones, and then run `-s100` on those specific images to polish details. This technique is **3-8x as quick**.
+
+:::tip[Time Savings Example]
+ Assuming 60 seconds per 100 steps:
+
+ - **Method A:** 60s * 100 images = **6000s** (100 images at `-s100`, manually picking 3 favorites). Total time: **1 hour and 40 minutes.**
+ - **Method B:** 6s * 100 images + 60s * 3 images = **780s** (100 images at `-s10`, manually picking 3 favorites, and running those 3 at `-s100` to polish details). Total time: **13 minutes.**
+:::
+
+## Three Key Takeaways
+
+Finally, it is relevant to mention that, in general, there are 3 important moments in the process of image formation as steps increase:
+
+
+1. **The Indicator Stage:**
+ The earliest point at which an image becomes a good indicator of the final result. This is useful for batch generation at low step values to preview outputs before committing to higher steps.
+2. **The Coherence Stage:**
+ The point at which an image becomes coherent, even if different from the final converged result. This is useful for low-step batch generation where quality is improved via other techniques (like inpainting) rather than raw step count.
+3. **The Convergence Stage:**
+ The point at which an image fully converges and stops changing.
+
+
+:::note[Workflow Dictates Strategy]
+ Remember that your workflow/strategy should define your optimal number of steps, even for the same prompt and seed. For example, if you seek full convergence, you may run `K_LMS` for `-s200`. However, running `K_LMS` for `-s20` (taking one-tenth the time) may perform just as well if your workflow includes adding small missing details via `img2img`.
+:::
+
+
diff --git a/docs/src/content/docs/concepts/prompt-syntax.mdx b/docs/src/content/docs/concepts/prompt-syntax.mdx
new file mode 100644
index 00000000000..5d26267c57a
--- /dev/null
+++ b/docs/src/content/docs/concepts/prompt-syntax.mdx
@@ -0,0 +1,138 @@
+---
+title: Prompting Syntax
+lastUpdated: 2026-03-30
+sidebar:
+ order: 3
+---
+
+import { Card, LinkCard, CardGrid } from '@astrojs/starlight/components';
+
+
+
+
+
+
+
+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`.
+:::
+
+## Quick reference
+
+
+ - Increase a single word: `trees+`
+ - Decrease a single word: `fog-`
+ - Weight a phrase: `(golden hour light)+`
+ - Use an exact numeric weight: `(cinematic lighting)1.25`
+ - Nest weights: `(portrait with (blue eyes)1.3)1.1`
+ - Blend prompts: `("portrait photo", "oil painting").blend(0.7, 0.3)`
+ - Conjoin clauses: `("red silk dress", "studio portrait", "soft rim light").and()`
+ - Escape literal parentheses: `colored pencil \(medium\)`
+
+
+## Attention weighting with `+` and `-`
+
+Append `+` to increase influence, or `-` to reduce it.
+
+```text
+freckles+
+background crowd-
+(soft rim light)++
+```
+
+Rules of thumb:
+
+- Single words can be weighted directly.
+- Multi-word phrases should be wrapped in parentheses.
+- Each additional `+` compounds upward.
+- Each additional `-` compounds downward in roughly 10% steps.
+
+:::tip[Start small]
+ One or two steps is usually enough. Extreme weighting can overpower the rest of the prompt.
+:::
+
+## Numeric weights
+
+Use numeric weights when you want precise control instead of repeated plus or minus markers.
+
+```text
+(cinematic lighting)1.25
+(background crowd)0.8
+(sharp focus)1.1
+```
+
+Guidelines:
+
+- `1` is neutral.
+- Values greater than `1` increase emphasis.
+- Values between `0` and `1` reduce emphasis.
+- Wrap the weighted phrase in parentheses.
+
+## Grouping and nesting
+
+You can group phrases and apply weight to the whole group, then nest another weighted phrase inside it.
+
+```text
+(portrait with (blue eyes)1.3)1.1
+```
+
+In this example, the outer group strengthens the whole phrase, and the inner group gives `blue eyes` even more emphasis.
+
+## Blend prompts with `.blend()`
+
+Use `.blend()` to mix the meaning of two or more prompts.
+
+```text
+("portrait photo, 85mm lens", "oil painting, visible brushstrokes").blend(0.7, 0.3)
+```
+
+This is most useful for combining concepts or styles that you want balanced deliberately.
+
+Tips:
+
+- Provide one weight for each prompt argument.
+- Keeping the weights near a total of `1` makes the result easier to reason about.
+- Quoted arguments are the safest choice, especially when the prompts contain commas.
+
+## Combine clauses with `.and()`
+
+Use `.and()` when you want separate prompt clauses encoded individually instead of as one long comma-separated sentence.
+
+```text
+("red silk dress", "studio portrait", "soft rim light").and()
+```
+
+This can behave differently from:
+
+```text
+red silk dress, studio portrait, soft rim light
+```
+
+If a normal prompt keeps collapsing ideas together, `.and()` is worth testing.
+
+## Escape literal parentheses
+
+Unescaped parentheses are treated as prompt syntax. If you want actual parentheses in the text, escape them with backslashes.
+
+```text
+colored pencil \(medium\)
+portrait \(realistic\) (high quality)1.2
+A bear \(with razor-sharp teeth\) in a forest
+```
+
+Use unescaped parentheses only when you mean grouping or weighting.
+
+## Related pages
+
+- For practical prompt-writing advice, read [Prompting Guide](../prompting-guide).
+- For prompt expansion and permutations, read [Dynamic Prompting](../dynamic-prompting).
diff --git a/docs/src/content/docs/concepts/prompting-guide.mdx b/docs/src/content/docs/concepts/prompting-guide.mdx
new file mode 100644
index 00000000000..3dbb27a9fbc
--- /dev/null
+++ b/docs/src/content/docs/concepts/prompting-guide.mdx
@@ -0,0 +1,180 @@
+---
+title: Prompting Guide
+lastUpdated: 2026-03-30
+sidebar:
+ order: 2
+---
+
+import { Card, CardGrid, Steps, LinkCard } from '@astrojs/starlight/components';
+
+
+
+
+
+
+
+Prompting in InvokeAI works best when you describe the image clearly, then refine only the parts that matter. This page focuses on practical prompt-writing habits.
+
+
+
+ Start with the main thing you want to see: a character, object, scene, or action.
+
+
+ Add the visual language: photograph, watercolor, oil painting, 3D render, anime illustration, and so on.
+
+
+ Describe the camera angle, framing, lighting, environment, color palette, or mood that will shape the image.
+
+
+ Add a few high-value quality cues such as fabric texture, shallow depth of field, natural skin texture, or painterly brushwork.
+
+
+
+A simple pattern that works well is:
+
+`subject, style or medium, lighting or composition, a few important details`
+
+Not every prompt needs every category. Start simple, then add detail only when the model needs more direction.
+
+## Positive and negative prompts
+
+
+
+ Use the positive prompt to describe what you want the model to create. Put the most important idea early and keep the wording concrete.
+
+
+ Use the negative prompt to remove recurring problems or unwanted traits. Keep it short and targeted instead of pasting a giant list into every generation.
+
+
+
+Good negative prompts usually name specific failure modes: `blurry`, `distorted hands`, `low detail`, `extra limbs`.
+
+:::tip[Negative prompts are strong]
+ A negative term can suppress nearby concepts too. If you negate something broad like `green` or `moss`, you may also weaken grass, foliage, or other related ideas.
+:::
+
+## A practical prompting workflow
+
+
+ 1. Start with the core image
+
+ Write the clearest version of the image you want before adding stylistic extras.
+
+ 2. Add style and composition
+
+ Once the subject is right, add medium, lens, lighting, mood, background, or framing details.
+
+ 3. Test with a fixed seed
+
+ When you are learning what a prompt change does, keep the seed stable so you can compare results directly.
+
+ 4. Change one thing at a time
+
+ If you add five new terms at once, you will not know which one helped.
+
+ 5. Escalate only when needed
+
+ 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).
+
+
+Here is the same idea refined in stages:
+
+```text
+portrait of a woman
+
+portrait of a woman, studio photograph, soft key light
+
+portrait of a woman, studio photograph, soft key light, 85mm lens, shallow depth of field, natural skin texture
+```
+
+## Write for the model you are using
+
+The same prompt can behave very differently across models.
+
+- Photo-oriented models respond well to camera, lens, lighting, and texture language.
+- Illustration models often respond better to medium, art direction, and shape language.
+- Specialty models may expect specific trigger words, subjects, or styles from their own model card.
+- If a prompt works beautifully on one model and poorly on another, that does not always mean the prompt is bad. The model may just speak a different visual language.
+
+## When advanced syntax helps
+
+Reach for advanced syntax when a normal comma-separated prompt is almost right, but you need more control.
+
+- 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](../dynamic-prompting) when you want many prompt variations from one template.
+
+## Common mistakes
+
+- Packing too many unrelated ideas into one prompt.
+- Using long generic quality-word lists before you know the base prompt works.
+- Treating the negative prompt as a trash can for every bad outcome.
+- Expecting identical behavior across models, schedulers, and workflows.
+- Changing prompt, model, seed, and settings all at once while troubleshooting.
+
+## Example prompts
+
+### Photographic portrait
+
+**Positive prompt**
+
+```text
+editorial portrait of a woman in a charcoal coat, studio photograph, soft key light, subtle rim light, 85mm lens, shallow depth of field, natural skin texture
+```
+
+**Negative prompt**
+
+```text
+blurry, low detail, waxy skin, extra fingers
+```
+
+### Environment concept art
+
+**Positive prompt**
+
+```text
+ancient stone temple built into a cliffside, fantasy concept art, misty sunrise, towering scale, moss-covered stairs, cinematic atmosphere
+```
+
+**Negative prompt**
+
+```text
+flat lighting, low contrast, muddy details
+```
+
+### Product-style render
+
+**Positive prompt**
+
+```text
+sleek ceramic teapot on a matte stone surface, product photography, clean studio lighting, soft shadow, high detail, minimal background
+```
+
+**Negative prompt**
+
+```text
+cluttered background, distortion, duplicate objects
+```
+
+### Stylized illustration
+
+**Positive prompt**
+
+```text
+fox courier crossing a rainy city street, storybook illustration, bold shapes, glowing shop signs, reflective pavement, warm and cool color contrast
+```
+
+**Negative prompt**
+
+```text
+photorealistic, dull colors, low detail
+```
diff --git a/docs/src/content/docs/configuration/assets/cuda-sysmem-fallback.png b/docs/src/content/docs/configuration/assets/cuda-sysmem-fallback.png
new file mode 100755
index 00000000000..f79e007f871
Binary files /dev/null and b/docs/src/content/docs/configuration/assets/cuda-sysmem-fallback.png differ
diff --git a/docs/src/content/docs/configuration/docker.mdx b/docs/src/content/docs/configuration/docker.mdx
new file mode 100644
index 00000000000..591ed38d3ae
--- /dev/null
+++ b/docs/src/content/docs/configuration/docker.mdx
@@ -0,0 +1,95 @@
+---
+title: Docker
+---
+
+import { Aside, Tabs, TabItem } from '@astrojs/starlight/components'
+
+import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
+
+
+
+:::note[Operating Systems and GPU Support]
+
+
+ Docker Desktop on Windows [includes GPU support](https://www.docker.com/blog/wsl-2-gpu-support-for-docker-desktop-on-nvidia-gpus/).
+
+
+ Docker can not access the GPU on macOS, so your generation speeds will be slow. Use the [launcher](../../start-here/installation) instead.
+
+
+ Configure Docker to access your machine's GPU.
+ Follow the [NVIDIA](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) or [AMD](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html) documentation.
+
+
+:::
+
+## TL;DR
+
+Ensure your Docker setup is able to use your GPU. Then:
+
+```bash
+docker run --runtime=nvidia --gpus=all --publish 9090:9090 ghcr.io/invoke-ai/invokeai
+```
+
+Once the container starts up, open [http://localhost:9090](http://localhost:9090) in your browser, install some models, and start generating.
+
+## Build-It-Yourself
+
+All the docker materials are located inside the [docker](https://github.com/invoke-ai/InvokeAI/tree/main/docker) directory in the Git repo.
+
+```bash
+cd docker
+cp .env.sample .env
+docker compose up
+```
+
+We also ship the `run.sh` convenience script. See the `docker/README.md` file for detailed instructions on how to customize the docker setup to your needs.
+
+### Prerequisites
+
+#### Install [Docker](https://github.com/santisbon/guides#docker)
+
+On the [Docker Desktop app](https://docs.docker.com/get-docker/), go to `Preferences` -> `Resources` -> `Advanced`. Increase the CPUs and Memory to avoid this [Issue](https://github.com/invoke-ai/InvokeAI/issues/342). You may need to increase Swap and Disk image size too.
+
+### Setup
+
+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 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.
+
+| Environment Variable | Default value | Description |
+| --- | --- | --- |
+| `INVOKEAI_ROOT` | `~/invokeai` | **Required** - the location of your InvokeAI root directory. It will be created if it does not exist. |
+| `HUGGING_FACE_HUB_TOKEN` | | InvokeAI will work without it, but some of the integrations with HuggingFace (like downloading from models from private repositories) may not work |
+| `GPU_DRIVER` | `cuda` | Optionally change this to `rocm` to build the image for AMD GPUs. NOTE: Use the `build.sh` script to build the image for this to take effect. |
+
+#### Build the Image
+
+Use the standard `docker compose build` command from within the `docker` directory.
+
+If using an AMD GPU:
+a: set the `GPU_DRIVER=rocm` environment variable in `docker-compose.yml` and continue using `docker compose build` as usual, or
+b: set `GPU_DRIVER=rocm` in the `.env` file and use the `build.sh` script, provided for convenience
+
+#### Run the Container
+
+Use the standard `docker compose up` command, and generally the `docker compose` [CLI](https://docs.docker.com/compose/reference/) as usual.
+
+Once the container starts up (and configures the InvokeAI root directory if this is a new installation), you can access InvokeAI at [http://localhost:9090](http://localhost:9090)
+
+## Troubleshooting / FAQ
+
+
+ "I am running Windows under WSL2, and am seeing a 'no such file or directory' error."
+
+ Your `docker-entrypoint.sh` might have has Windows (CRLF) line endings, depending how you cloned the repository.
+ To solve this, change the line endings in the `docker-entrypoint.sh` file to `LF`. You can do this in VSCode
+ (`Ctrl+P` and search for "line endings"), or by using the `dos2unix` utility in WSL.
+ Finally, you may delete `docker-entrypoint.sh` followed by `git pull; git checkout docker/docker-entrypoint.sh`
+ to reset the file to its most recent version.
+ For more information on this issue, see [Docker Desktop documentation](https://docs.docker.com/desktop/troubleshoot/topics/#avoid-unexpected-syntax-errors-use-unix-style-line-endings-for-files-in-containers)
+
+
diff --git a/docs/src/content/docs/configuration/fp8-storage.mdx b/docs/src/content/docs/configuration/fp8-storage.mdx
new file mode 100644
index 00000000000..799821b079e
--- /dev/null
+++ b/docs/src/content/docs/configuration/fp8-storage.mdx
@@ -0,0 +1,128 @@
+---
+title: FP8 Storage
+sidebar:
+ order: 3
+---
+
+import { Steps } from '@astrojs/starlight/components';
+
+FP8 Storage cuts a model's VRAM footprint roughly in half by keeping weights on the GPU in 8-bit floating-point format (`float8_e4m3fn`). During inference, each layer's weights are cast on-the-fly back up to the compute precision (FP16/BF16), then cast back to FP8 after the forward pass — so quality is largely preserved.
+
+It pairs well with [Low-VRAM mode](/configuration/low-vram-mode/): low-VRAM mode streams layers between RAM and VRAM, while FP8 Storage shrinks the layers themselves.
+
+:::caution[For full precision models only]
+FP8 Storage only applies to **full precision** checkpoints (FP16 / BF16 / FP32). It is **silently a no-op** for already-quantized formats — **GGUF**, **NF4**, and **int8** checkpoints carry their own storage precision and the loader returns a different module type that the FP8 layer cast does not touch. If your model is already quantized, the toggle has no effect; use the full-precision variant of the model if you want to enable FP8 Storage.
+:::
+
+## Requirements
+
+- **Nvidia GPU on Windows or Linux.** FP8 Storage uses CUDA tensor types and is silently disabled on CPU and MPS.
+- **CUDA 12.x and recent PyTorch.** The `float8_e4m3fn` dtype was added in PyTorch 2.1 — InvokeAI's bundled versions satisfy this.
+
+There is no hardware requirement for FP8 *compute* — InvokeAI casts back to FP16/BF16 for math. This means FP8 Storage works on GPUs that do not natively support FP8 matmul (e.g. RTX 30-series), at a small per-step throughput cost.
+
+## Hardware support tiers
+
+InvokeAI's FP8 path stores weights in FP8 and casts them back to BF16/FP16 on each forward pass via its own `register_forward_pre_hook` / `register_forward_hook` wrappers (the same skip list as diffusers' `apply_layerwise_casting`, but applied to every `nn.Module` — including diffusers `ModelMixin` subclasses — so it composes correctly with InvokeAI's `CustomLinear` and partial loading). The practical benefit of toggling FP8 Storage depends on what your GPU can do natively. There are three tiers:
+
+### RTX 30-series and older Ampere workstation cards — VRAM win only
+
+The toggle works as advertised: the UNet / transformer drops by roughly 50% on the GPU. Per-step latency is the same or marginally slower because every forward pass adds an FP8 → BF16 cast on entry and a BF16 → FP8 cast on exit. This is the **largest target group**: 3090 owners squeezing FLUX into 24 GB benefit the most.
+
+### RTX 40-series, RTX 50-series, and Hopper — VRAM win today, compute win possible later
+
+These GPUs have native FP8 tensor cores. The toggle still buys you the same ~50% VRAM reduction today, because the forward pass still runs in BF16 — the hook casts weights back up to compute precision before each layer. If InvokeAI later wires up a true FP8 matmul path (e.g. via `torchao`), the same toggle will *also* unlock compute speedups on this hardware. Until then, treat the benefit as "VRAM only, same as Ampere".
+
+### Older CUDA cards — still a VRAM win
+
+`float8_e4m3fn` is a pure storage dtype in PyTorch and works on any CUDA device, so pre-Ampere cards (GTX 16-series, RTX 20-series, etc.) get the same ~50% VRAM reduction as Ampere. There are no native FP8 tensor cores on these GPUs, so the throughput trade-off is the same as on the 30-series: cast in, compute in BF16/FP16, cast back out.
+
+### MPS and CPU — no-op
+
+FP8 Storage is silently disabled on anything that is not CUDA. On CPU PyTorch *technically* supports FP8 dtypes, but the cast operations are software-emulated and end up costing more than the memory savings buy back, so InvokeAI gates the entire path on `device.type == "cuda"`. If you toggle it on CPU or MPS, the loader skips the cast and returns the model unchanged with no log line.
+
+## Enabling FP8 Storage
+
+FP8 Storage is a **per-model setting**, configured from the Model Manager:
+
+
+1. Open the **Model Manager**.
+2. Select a model (Main, ControlNet, or T2I-Adapter).
+3. Under **Default Settings**, toggle **FP8 Storage (Save VRAM)**.
+4. Click **Save**.
+
+
+The setting takes effect on the next load. If the model is already in the cache, InvokeAI evicts the cached copy automatically so the new setting applies — even if a generation is currently using the model (the eviction is deferred until the generation finishes).
+
+:::tip[When to enable]
+ Enable FP8 Storage on large models that don't fit comfortably in VRAM — FLUX dev/Klein, large SDXL checkpoints, ControlNet-XL adapters. For smaller SD1 / SD2 models, the savings are negligible and not worth the small precision trade-off.
+:::
+
+## What FP8 Storage applies to
+
+FP8 Storage is **only** applied to layers where the precision trade-off is acceptable:
+
+| Model type | FP8 applied? |
+| ----------------------------- | -------------------------------------- |
+| Main models (SD1, SD2, SDXL) | Yes |
+| FLUX.1 / FLUX.2 Klein | Yes |
+| ControlNet, T2I-Adapter | Yes |
+| VAE | No — visible decode-quality regression |
+| Text encoders, tokenizers | No — small models, no benefit |
+| Z-Image (any variant) | No — dtype mismatch with skipped layers|
+| LoRA, ControlLoRA | No — patched into base, not run alone |
+
+Within a supported model, **norm layers, position/patch embeddings, and `proj_in`/`proj_out` are skipped** so precision-sensitive tiny learned scalars (e.g. FLUX `RMSNorm.scale`) aren't crushed to FP8. This mirrors the diffusers default skip list.
+
+## Quality trade-offs
+
+FP8 Storage is **near-lossless** for most workloads because:
+
+- Norms and embeddings (the precision-sensitive layers) are skipped.
+- The actual matmul still happens in FP16/BF16 — FP8 is only the on-GPU storage format.
+
+That said, some artifacts have been reported on:
+
+- **VAEs** — never cast (the toggle has no effect on VAE submodels).
+- **Heavy LoRA stacks** — patching is unaffected, but very precision-sensitive LoRAs may show slight drift. Compare a side-by-side if your workflow depends on subtle LoRA behavior.
+
+If you see unexpected quality regressions, disable FP8 Storage on the affected model and re-run.
+
+## Combining with Low-VRAM mode
+
+**FP8 + partial loading**: fully supported. FP8 Storage shrinks the layers; partial loading streams them between RAM and VRAM as needed. Use both on tight VRAM budgets.
+
+(For why FP8 Storage doesn't stack on top of GGUF / NF4 / int8 checkpoints, see the callout at the top of this page.)
+
+## Troubleshooting
+
+### "I toggled FP8 Storage but VRAM usage didn't change"
+
+The cache eviction is immediate for idle models, but **deferred until the next unlock** if the model is mid-generation. Wait for the current generation to finish, then start a new one — the next load will use the new setting.
+
+If VRAM still hasn't dropped:
+
+- Check the InvokeAI log for `FP8 layerwise casting enabled for `. If the line isn't there, the model is on the exclusion list (VAE, text encoder, Z-Image, LoRA — see table above).
+- Confirm you are on CUDA. FP8 Storage is silently disabled on CPU and MPS.
+
+### Quality regression on a specific model
+
+Disable FP8 Storage for that model in Model Manager and reload. If quality is restored, the model has FP8-sensitive layers that fall outside the default skip list. Please open an issue with the model name and a side-by-side comparison.
+
+### "RuntimeError: ... float8_e4m3fn ..."
+
+You're on a PyTorch version that predates FP8 support. Reinstall InvokeAI using the official launcher — the bundled torch version supports FP8.
+
+### Reporting an FP8 issue
+
+If FP8 Storage misbehaves — crash, quality regression, OOM that shouldn't happen — please [open a GitHub issue](https://github.com/invoke-ai/InvokeAI/issues/new/choose) and include:
+
+- **What you did**: the workflow / generation step that triggered the problem, and whether it reproduces every time.
+- **Model**: exact name and variant (e.g. "FLUX.2 Klein 9B Diffusers", "SDXL Base 1.0 single-file"), and whether the file is a full-precision checkpoint or already quantized (GGUF / NF4 / int8).
+- **LoRAs**: whether any LoRAs (or ControlLoRAs) are stacked on the model, and how many.
+- **Other toggles**: Low-VRAM mode on/off, any `cpu_only` text encoder setting, configured VRAM limit.
+- **GPU**: model and VRAM size (e.g. "RTX 3090 24 GB", "RTX 4070 Ti 12 GB").
+- **OS**: Windows or Linux, plus driver / CUDA version if you have it.
+- **Logs**: the InvokeAI log around the failure — in particular the `FP8 layerwise casting enabled for ` line (or its absence) and any traceback.
+
+A side-by-side image comparison (FP8 on vs. FP8 off, same seed) is extremely useful for quality regressions.
diff --git a/docs/src/content/docs/configuration/invokeai-yaml.mdx b/docs/src/content/docs/configuration/invokeai-yaml.mdx
new file mode 100644
index 00000000000..1c79fbf82aa
--- /dev/null
+++ b/docs/src/content/docs/configuration/invokeai-yaml.mdx
@@ -0,0 +1,266 @@
+---
+title: YAML Config
+sidebar:
+ order: 1
+---
+
+import { FileTree } from '@astrojs/starlight/components'
+import SettingsDocs from '@lib/components/SettingsDocs.astro'
+
+Runtime settings, including the location of files and directories, memory usage, and performance, are managed via the `invokeai.yaml` config file or environment variables. A subset of settings may be set via commandline arguments.
+
+Settings sources are used in this order:
+
+- CLI args
+- Environment variables
+- `invokeai.yaml` settings
+- Fallback: defaults
+
+### InvokeAI Root Directory
+
+On startup, InvokeAI searches for its "root" directory. This is the directory that contains models, images, the database, and so on. It also contains a configuration file called `invokeai.yaml`.
+
+
+ - models/
+ - outputs/
+ - databases/
+ - workflow_thumbnails/
+ - style_presets/
+ - nodes/
+ - configs/
+ - invokeai.example.yaml
+ - **invokeai.yaml**
+
+
+InvokeAI searches for the root directory in this order:
+
+1. The `--root ` CLI arg.
+2. The environment variable INVOKEAI_ROOT.
+3. The directory containing the currently active virtual environment.
+4. Fallback: a directory in the current user's home directory named `invokeai`.
+
+### InvokeAI Configuration File
+
+Inside the root directory, we read settings from the `invokeai.yaml` file.
+
+It has two sections - one for internal use and one for user settings:
+
+```yaml
+# Internal metadata - do not edit:
+schema_version: 4.0.2
+
+# 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
+```
+
+The settings in this file will override the defaults. You only need
+to change this file if the default for a particular setting doesn't
+work for you.
+
+You'll find an example file next to `invokeai.yaml` that shows the default values.
+
+Some settings, like [Model Marketplace API Keys], require the YAML
+to be formatted correctly. Here is a [basic guide to YAML files].
+
+#### Custom Config File Location
+
+You can use any config file with the `--config` CLI arg. Pass in the path to the `invokeai.yaml` file you want to use.
+
+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.
+
+#### Multi-GPU Generation
+
+On a machine with more than one GPU, InvokeAI can run several generation sessions at the same time — one per GPU — instead of processing the queue one job at a time. Jobs are distributed fairly across users, so a single user's large batch cannot monopolize every GPU while others wait.
+
+This is controlled by the `generation_devices` setting:
+
+```yaml
+generation_devices: auto # default value
+```
+
+| Value | Behavior |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `auto` | Use every available CUDA GPU, running one generation session per GPU concurrently. This is the default. |
+| `[cuda:0,cuda:1]` | Use the specific devices listed, one session per device. Useful for reserving a GPU for other work. |
+| `[cuda:0]` | Use a single specific device. Generation runs serially, as it did before multi-GPU support. |
+| `[]` | Use the first detected device. Generation runs serially, as it did before multi-GPU support. |
+
+Each entry in the list must be one of `cpu`, `cuda`, `mps`, or `cuda:N`, where `N` is a zero-based device number (`cuda:0` is the first GPU, `cuda:1` the second, and so on).
+
+```yaml
+# Use the first and third GPUs, leaving the second free for other tasks
+generation_devices: [cuda:0, cuda:2]
+```
+
+Notes:
+
+- On a system without a CUDA GPU, `auto` resolves to the single best available device (`mps` on Apple Silicon, otherwise `cpu`), so generation runs serially.
+- Each active GPU gets its own model cache, and model weights are duplicated in system RAM for every device. Running many GPUs in parallel therefore increases RAM usage — ensure you have ample system memory before enabling a large device list.
+- Duplicate entries are ignored; `[cuda:0, cuda:0]` is treated as `[cuda:0]`.
+- You can restrict which physical GPUs InvokeAI sees with the `CUDA_VISIBLE_DEVICES` environment variable. When set, `auto` only enumerates the visible subset, and `cuda:N` indices refer to positions within that subset.
+
+During parallel generation, the progress display shows one progress bar per active session, stacked vertically, each disappearing as its session completes.
+
+#### Text Encoder Offload to Idle GPUs
+
+When more than one GPU is configured for generation but not all of them are busy, InvokeAI can run a session's text/prompt encoder on a currently-idle GPU instead of the GPU running its denoise pipeline. This avoids evicting the denoise model from VRAM just to make room for the encoder, and lets the cached encoder be reused across generations — making repeated generations noticeably smoother.
+
+This is controlled by the `offload_text_encoders_to_idle_gpus` setting:
+
+```yaml
+offload_text_encoders_to_idle_gpus: true # default value
+```
+
+| Value | Behavior |
+| ------- | ---------------------------------------------------------------------------------------------------------------- |
+| `true` | Run text encoders on an idle GPU when one is available. This is the default. |
+| `false` | Always run text encoders on the same GPU as the rest of the pipeline (the behavior before this feature existed). |
+
+Notes:
+
+- This has no effect unless at least two `generation_devices` are configured. On a single device — or when every GPU is already busy with its own session — encoders run on the session's own GPU, exactly as if the setting were `false`.
+- It is purely a placement optimization and does not change generated images.
+- A borrowed GPU is used exclusively for the encoder while it runs, so it never interferes with a generation session running on that same GPU.
+
+#### Image Subfolder Strategy
+
+By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, in the Settings panel.
+
+```yaml
+image_subfolder_strategy: flat # default value
+```
+
+Available strategies:
+
+| Strategy | Example Path | Description |
+| -------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| `flat` | `outputs/images/abc123.png` | Store images directly in the images directory. |
+| `date` | `outputs/images/2026/03/17/abc123.png` | Organize images by creation date. |
+| `type` | `outputs/images/general/abc123.png` | Organize images by image category. |
+| `hash` | `outputs/images/ab/abc123.png` | Use the first two characters of the image UUID for filesystem performance with large collections. |
+
+Changing this setting only affects newly-created images. Existing images remain in their current locations.
+
+#### 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_`
+to the variable name. For example, `INVOKEAI_HOST` would set the `host`
+setting.
+
+For non-primitive values, pass a JSON-encoded string:
+
+```sh
+export INVOKEAI_REMOTE_API_TOKENS='[{"url_regex":"modelmarketplace", "token": "12345"}]'
+```
+
+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.
+
+
+
+[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
diff --git a/docs/src/content/docs/configuration/low-vram-mode.mdx b/docs/src/content/docs/configuration/low-vram-mode.mdx
new file mode 100644
index 00000000000..fa15cef8735
--- /dev/null
+++ b/docs/src/content/docs/configuration/low-vram-mode.mdx
@@ -0,0 +1,182 @@
+---
+title: Low-VRAM mode
+sidebar:
+ order: 2
+---
+
+As of v5.6.0, Invoke has a low-VRAM mode. It works on systems with dedicated GPUs (Nvidia GPUs on Windows/Linux and AMD GPUs on Linux).
+
+This allows you to generate even if your GPU doesn't have enough VRAM to hold full models. Most users should be able to run even the beefiest models - like the ~24GB unquantised FLUX dev model.
+
+## Enabling Low-VRAM mode
+
+Low-VRAM mode is **enabled by default** via the `enable_partial_loading: true` setting in `invokeai.yaml`. No action is required to turn it on.
+
+**Windows users should also [disable the Nvidia sysmem fallback](#disabling-nvidia-sysmem-fallback-windows-only)**.
+
+It is possible to fine-tune the settings for best performance or if you still get out-of-memory errors (OOMs).
+
+If you want to disable partial loading (e.g. on systems with plenty of VRAM where full loading is faster), add this line to your `invokeai.yaml` and restart Invoke:
+
+```yaml
+enable_partial_loading: false
+```
+
+:::tip[How to find `invokeai.yaml`]
+ The `invokeai.yaml` configuration file lives in your install directory. To access it, run the **Invoke Community Edition** launcher and click the install location. This will open your install directory in a file explorer window.
+
+ You'll see `invokeai.yaml` there and can edit it with any text editor. After making changes, restart Invoke.
+
+ If you don't see `invokeai.yaml`, launch Invoke once. It will create the file on its first startup.
+:::
+
+## Details and fine-tuning
+
+Low-VRAM mode involves 4 features, each of which can be configured or fine-tuned:
+
+- Partial model loading (`enable_partial_loading`)
+- PyTorch CUDA allocator config (`pytorch_cuda_alloc_conf`)
+- Dynamic RAM and VRAM cache sizes (`max_cache_ram_gb`, `max_cache_vram_gb`)
+- Working memory (`device_working_mem_gb`)
+- Keeping a RAM weight copy (`keep_ram_copy_of_weights`)
+
+Read on to learn about these features and understand how to fine-tune them for your system and use-cases.
+
+### Partial model loading
+
+Invoke's partial model loading works by streaming model "layers" between RAM and VRAM as they are needed.
+
+When an operation needs layers that are not in VRAM, but there isn't enough room to load them, inactive layers are offloaded to RAM to make room.
+
+#### Enabling partial model loading
+
+Partial model loading is enabled by default. The corresponding setting in `invokeai.yaml` is:
+
+```yaml
+enable_partial_loading: true
+```
+
+Set it to `false` to disable partial loading.
+
+### PyTorch CUDA allocator config
+
+The PyTorch CUDA allocator's behavior can be configured using the `pytorch_cuda_alloc_conf` config. Tuning the allocator configuration can help to reduce the peak reserved VRAM. The optimal configuration is dependent on many factors (e.g. device type, VRAM, CUDA driver version, etc.), but switching from PyTorch's native allocator to using CUDA's built-in allocator works well on many systems. To try this, add the following line to your `invokeai.yaml` file:
+
+```yaml
+pytorch_cuda_alloc_conf: "backend:cudaMallocAsync"
+```
+
+A more complete explanation of the available configuration options is [here](https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf).
+
+### Dynamic RAM and VRAM cache sizes
+
+Loading models from disk is slow and can be a major bottleneck for performance. Invoke uses two model caches - RAM and VRAM - to reduce loading from disk to a minimum.
+
+By default, Invoke manages these caches' sizes dynamically for best performance.
+
+#### Fine-tuning cache sizes
+
+Prior to v5.6.0, the cache sizes were static, and for best performance, many users needed to manually fine-tune the `ram` and `vram` settings in `invokeai.yaml`.
+
+As of v5.6.0, the caches are dynamically sized. The `ram` and `vram` settings are no longer used, and new settings are added to configure the cache.
+
+**Most users will not need to fine-tune the cache sizes.**
+
+But, if your GPU has enough VRAM to hold models fully, you might get a perf boost by manually setting the cache sizes in `invokeai.yaml`:
+
+```yaml
+# The default max cache RAM size is logged on InvokeAI startup. It is determined based on your system RAM / VRAM.
+# You can override the default value by setting `max_cache_ram_gb`.
+# Increasing `max_cache_ram_gb` will increase the amount of RAM used to cache inactive models, resulting in faster model
+# reloads for the cached models.
+# As an example, if your system has 32GB of RAM and no other heavy processes, setting the `max_cache_ram_gb` to 28GB
+# might be a good value to achieve aggressive model caching.
+max_cache_ram_gb: 28
+
+# The default max cache VRAM size is adjusted dynamically based on the amount of available VRAM (taking into
+# consideration the VRAM used by other processes).
+# You can override the default value by setting `max_cache_vram_gb`.
+# CAUTION: Most users should not manually set this value. See warning below.
+max_cache_vram_gb: 16
+```
+
+:::caution[Max safe value for `max_cache_vram_gb`]
+ Most users should not manually configure the `max_cache_vram_gb`. This configuration value takes precedence over the `device_working_mem_gb` and any operations that explicitly reserve additional working memory (e.g. VAE decode). As such, manually configuring it increases the likelihood of encountering out-of-memory errors.
+
+ For users who wish to configure `max_cache_vram_gb`, the max safe value can be determined by subtracting `device_working_mem_gb` from your GPU's VRAM. As described below, the default for `device_working_mem_gb` is 3GB.
+
+ For example, if you have a 12GB GPU, the max safe value for `max_cache_vram_gb` is `12GB - 3GB = 9GB`.
+
+ If you had increased `device_working_mem_gb` to 4GB, then the max safe value for `max_cache_vram_gb` is `12GB - 4GB = 8GB`.
+
+ Most users who override `max_cache_vram_gb` are doing so because they wish to use significantly less VRAM, and should be setting `max_cache_vram_gb` to a value significantly less than the 'max safe value'.
+:::
+
+### Working memory
+
+Invoke cannot use _all_ of your VRAM for model caching and loading. It requires some VRAM to use as working memory for various operations.
+
+Invoke reserves 3GB VRAM as working memory by default, which is enough for most use-cases. However, it is possible to fine-tune this setting if you still get OOMs.
+
+#### Fine-tuning working memory
+
+You can increase the working memory size in `invokeai.yaml` to prevent OOMs:
+
+```yaml
+# The default is 3GB - bump it up to 4GB to prevent OOMs.
+device_working_mem_gb: 4
+```
+
+:::tip[Operations may request more working memory]
+ For some operations, we can determine VRAM requirements in advance and allocate additional working memory to prevent OOMs.
+
+ VAE decoding is one such operation. This operation converts the generation process's output into an image. For large image outputs, this might use more than the default working memory size of 3GB.
+
+ During this decoding step, Invoke calculates how much VRAM will be required to decode and requests that much VRAM from the model manager. If the amount exceeds the working memory size, the model manager will offload cached model layers from VRAM until there's enough VRAM to decode.
+
+ Once decoding completes, the model manager "reclaims" the extra VRAM allocated as working memory for future model loading operations.
+:::
+
+### Keeping a RAM weight copy
+
+Invoke has the option of keeping a RAM copy of all model weights, even when they are loaded onto the GPU. This optimization is _on_ by default, and enables faster model switching and LoRA patching. Disabling this feature will reduce the average RAM load while running Invoke (peak RAM likely won't change), at the cost of slower model switching and LoRA patching. If you have limited RAM, you can disable this optimization:
+
+```yaml
+# Set to false to reduce the average RAM usage at the cost of slower model switching and LoRA patching.
+keep_ram_copy_of_weights: false
+```
+
+### Disabling Nvidia sysmem fallback (Windows only)
+
+On Windows, Nvidia GPUs are able to use system RAM when their VRAM fills up via **sysmem fallback**. While it sounds like a good idea on the surface, in practice it causes massive slowdowns during generation.
+
+It is strongly suggested to disable this feature:
+
+- Open the **NVIDIA Control Panel** app.
+- Expand **3D Settings** on the left panel.
+- Click **Manage 3D Settings** in the left panel.
+- Find **CUDA - Sysmem Fallback Policy** in the right panel and set it to **Prefer No Sysmem Fallback**.
+
+
+
+:::tip[Invoke does the same thing, but better]
+ If the sysmem fallback feature sounds familiar, that's because Invoke's partial model loading strategy is conceptually very similar - use VRAM when there's room, else fall back to RAM.
+
+ Unfortunately, the Nvidia implementation is not optimized for applications like Invoke and does more harm than good.
+:::
+
+## Troubleshooting
+
+### Windows page file
+
+Invoke has high virtual memory (a.k.a. 'committed memory') requirements. This can cause issues on Windows if the page file size limits are hit. (See this issue for the technical details on why this happens: https://github.com/invoke-ai/InvokeAI/issues/7563).
+
+If you run out of page file space, InvokeAI may crash. Often, these crashes will happen with one of the following errors:
+
+- InvokeAI exits with Windows error code `3221225477`
+- InvokeAI crashes without an error, but `eventvwr.msc` reveals an error with code `0xc0000005` (the hex equivalent of `3221225477`)
+
+If you are running out of page file space, try the following solutions:
+
+- Make sure that you have sufficient disk space for the page file to grow. Watch your disk usage as Invoke runs. If it climbs near 100% leading up to the crash, then this is very likely the source of the issue. Clear out some disk space to resolve the issue.
+- Make sure that your page file is set to "System managed size" (this is the default) rather than a custom size. Under the "System managed size" policy, the page file will grow dynamically as needed.
diff --git a/docs/src/content/docs/configuration/patchmatch.mdx b/docs/src/content/docs/configuration/patchmatch.mdx
new file mode 100644
index 00000000000..a91e1a8f8b7
--- /dev/null
+++ b/docs/src/content/docs/configuration/patchmatch.mdx
@@ -0,0 +1,126 @@
+---
+title: Patchmatch
+---
+
+import { Tabs, TabItem, Steps } from '@astrojs/starlight/components'
+
+PatchMatch is an algorithm used to infill images. It can greatly improve outpainting results. PyPatchMatch is a python wrapper around a C++ implementation of the algorithm.
+
+It uses the image data around the target area as a reference to generate new image data of a similar character and quality.
+
+## Why Use PatchMatch
+
+In the context of image generation, "outpainting" refers to filling in a transparent area using AI-generated image data. But the AI can't generate without some initial data. We need to first fill in the transparent area with _something_.
+
+The first step in "outpainting" then, is to fill in the transparent area with something. Generally, you get better results when that initial infill resembles the rest of the image.
+
+Because PatchMatch generates image data so similar to the rest of the image, it works very well as the first step in outpainting, typically producing better results than other infill methods supported by Invoke (e.g. LaMA, cv2 infill, random tiles).
+
+### Performance Caveat
+
+PatchMatch is CPU-bound, and the amount of time it takes increases proportionally as the infill area increases. While the numbers certainly vary depending on system specs, you can expect a noticeable slowdown once you start infilling areas around 512x512 pixels. 1024x1024 pixels can take several seconds to infill.
+
+## Installation
+
+Unfortunately, installation can be somewhat challenging, as it requires some things that `pip` cannot install for you.
+
+
+ 1. Ensure you have the necessary dependencies installed for your system (see below).
+
+
+
+ You're in luck! On Windows platforms PyPatchMatch will install automatically on Windows systems with no extra intervention.
+
+
+ You need to have opencv installed so that pypatchmatch can be built:
+
+ ```bash
+ brew install opencv
+ ```
+
+ The next time you start `invoke`, after successfully installing opencv, pypatchmatch will be built.
+
+
+ Prior to installing PyPatchMatch, you need to take the following steps:
+
+
+
+
+ 1. Install the `build-essential` tools:
+
+ ```sh
+ sudo apt update # Update package lists
+ sudo apt install build-essential
+ ```
+
+ 2. Install `opencv`:
+
+ ```sh
+ sudo apt install python3-opencv libopencv-dev
+ ```
+
+ 3. Activate the environment you use for invokeai, either with `conda` or with a virtual environment.
+
+
+
+
+ 1. Install the `base-devel` package:
+
+ ```sh
+ sudo pacman -Syu
+ sudo pacman -S --needed base-devel
+ ```
+
+ 2. Install `opencv`, `blas`, and required dependencies:
+
+ ```sh
+ sudo pacman -S opencv blas fmt glew vtk hdf5
+ ```
+
+ or for CUDA support
+
+ ```sh
+ sudo pacman -S opencv-cuda blas fmt glew vtk hdf5
+ ```
+
+ 3. Fix the naming of the `opencv` package configuration file:
+
+ ```sh
+ cd /usr/lib/pkgconfig/
+ ln -sf opencv4.pc opencv.pc
+ ```
+
+
+
+
+
+
+ 2. Install pypatchmatch:
+
+ ```sh
+ pip install pypatchmatch
+ ```
+
+ 3. Confirm that pypatchmatch is installed. At the command-line prompt enter `python`, and then at the `>>>` line type `from patchmatch import patch_match`: It should look like the following:
+
+ ```py
+ Python 3.12.3 (main, Aug 14 2025, 17:47:21) [GCC 13.3.0] on linux
+ Type "help", "copyright", "credits" or "license" for more information.
+ >>> from patchmatch import patch_match
+ Compiling and loading c extensions from "/home/lstein/Projects/InvokeAI/.invokeai-env/src/pypatchmatch/patchmatch".
+ rm -rf build/obj libpatchmatch.so
+ mkdir: created directory 'build/obj'
+ mkdir: created directory 'build/obj/csrc/'
+ [dep] csrc/masked_image.cpp ...
+ [dep] csrc/nnf.cpp ...
+ [dep] csrc/inpaint.cpp ...
+ [dep] csrc/pyinterface.cpp ...
+ [CC] csrc/pyinterface.cpp ...
+ [CC] csrc/inpaint.cpp ...
+ [CC] csrc/nnf.cpp ...
+ [CC] csrc/masked_image.cpp ...
+ [link] libpatchmatch.so ...
+ ```
+
+ If you're not seeing any errors, you're ready to go!
+
diff --git a/docs/src/content/docs/contributing/code-of-conduct.md b/docs/src/content/docs/contributing/code-of-conduct.md
new file mode 100644
index 00000000000..8ada3a81b9b
--- /dev/null
+++ b/docs/src/content/docs/contributing/code-of-conduct.md
@@ -0,0 +1,130 @@
+---
+title: Code of Conduct
+---
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior
+may be reported to the community leaders responsible for enforcement
+at https://github.com/invoke-ai/InvokeAI/issues. All complaints will
+be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/docs/src/content/docs/contributing/contributors.md b/docs/src/content/docs/contributing/contributors.md
new file mode 100644
index 00000000000..eb57feb295e
--- /dev/null
+++ b/docs/src/content/docs/contributing/contributors.md
@@ -0,0 +1,54 @@
+---
+title: Contributors
+---
+
+We thank [all contributors](https://github.com/invoke-ai/InvokeAI/graphs/contributors) for their time and hard work!
+
+## Original Author
+
+- [Lincoln D. Stein](mailto:lincoln.stein@gmail.com)
+
+## Current Core Team
+
+- [@lstein](https://github.com/lstein) (Lincoln Stein) - Co-maintainer
+- [@blessedcoolant](https://github.com/blessedcoolant) - Co-maintainer
+- [@hipsterusername](https://github.com/hipsterusername) (Kent Keirsey) - Co-maintainer, CEO, Positive Vibes
+- [@psychedelicious](https://github.com/psychedelicious) (Spencer Mabrito) - Web Team Leader
+- [@joshistoast](https://github.com/joshistoast) (Josh Corbett) - Web Development
+- [@cheerio](https://github.com/cheerio) (Mary Rogers) - Lead Engineer & Web App Development
+- [@ebr](https://github.com/ebr) (Eugene Brodsky) - Cloud/DevOps/Software engineer; your friendly neighbourhood cluster-autoscaler
+- [@sunija](https://github.com/sunija) - Standalone version
+- [@brandon](https://github.com/brandon) (Brandon Rising) - Platform, Infrastructure, Backend Systems
+- [@ryanjdick](https://github.com/ryanjdick) (Ryan Dick) - Machine Learning & Training
+- [@JPPhoto](https://github.com/JPPhoto) - Core image generation nodes
+- [@dunkeroni](https://github.com/dunkeroni) - Image generation backend
+- [@SkunkWorxDark](https://github.com/SkunkWorxDark) - Image generation backend
+- [@glimmerleaf](https://github.com/glimmerleaf) (Devon Hopkins) - Community Wizard
+- [@gogurt](https://github.com/gogurt) enjoyer - Discord moderator and end user support
+- [@whosawhatsis](https://github.com/whosawhatsis) - Discord moderator and end user support
+- [@dwringer](https://github.com/dwringer) - Discord moderator and end user support
+- [@526christian](https://github.com/526christian) - Discord moderator and end user support
+- [@harvester62](https://github.com/harvester62) - Discord moderator and end user support
+
+## Honored Team Alumni
+
+- [@StAlKeR7779](https://github.com/StAlKeR7779) (Sergey Borisov) - Torch stack, ONNX, model management, optimization
+- [@damian0815](https://github.com/damian0815) - Attention Systems and Compel Maintainer
+- [@netsvetaev](https://github.com/netsvetaev) (Artur) - Localization support
+- [@Kyle0654](https://github.com/Kyle0654) (Kyle Schouviller) - Node Architect and General Backend Wizard
+- [@tildebyte](https://github.com/tildebyte) - Installation and configuration
+- [@mauwii](https://github.com/mauwii) (Matthias Wilde) - Installation, release, continuous integration
+- [@chainchompa](https://github.com/chainchompa) (Jennifer Player) - Web Development & Chain-Chomping
+- [@millu](https://github.com/millu) (Millun Atluri) - Community Wizard, Documentation, Node-wrangler,
+- [@genomancer](https://github.com/genomancer) (Gregg Helt) - Controlnet support
+- [@keturn](https://github.com/keturn) (Kevin Turner) - Diffusers
+
+## Original CompVis (Stable Diffusion) Authors
+
+- [Robin Rombach](https://github.com/rromb)
+- [Patrick von Platen](https://github.com/patrickvonplaten)
+- [ablattmann](https://github.com/ablattmann)
+- [Patrick Esser](https://github.com/pesser)
+- [owenvincent](https://github.com/owenvincent)
+- [apolinario](https://github.com/apolinario)
+- [Charles Packer](https://github.com/cpacker)
diff --git a/docs/src/content/docs/contributing/external-providers.md b/docs/src/content/docs/contributing/external-providers.md
new file mode 100644
index 00000000000..13dfd2df5b3
--- /dev/null
+++ b/docs/src/content/docs/contributing/external-providers.md
@@ -0,0 +1,131 @@
+---
+title: 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:///"`
+- `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: `/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__api_key` and optional `external__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:///"`.
+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:///`
+
+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.
diff --git a/docs/src/content/docs/contributing/index.md b/docs/src/content/docs/contributing/index.md
new file mode 100644
index 00000000000..e4da6da1746
--- /dev/null
+++ b/docs/src/content/docs/contributing/index.md
@@ -0,0 +1,56 @@
+---
+title: Contributing to InvokeAI
+sidebar:
+ order: 1
+---
+
+Invoke originated as a project built by the community, and that vision carries forward today as we aim to build the best pro-grade tools available. We work together to incorporate the latest in AI/ML research, making these tools available in over 20 languages to artists and creatives around the world as part of our fully permissive OSS project designed for individual users to self-host and use.
+
+We welcome contributions, whether features, bug fixes, code cleanup, testing, code reviews, documentation or translation. Please check in with us before diving in to code to ensure your work aligns with our vision.
+
+## Development
+
+If you’d like to help with development, please see our [development guide](/development/).
+
+**New Contributors:** If you’re unfamiliar with contributing to open source projects, take a look at our [new contributor guide](/contributing/new-contributor-guide/).
+
+## Nodes
+
+If you’d like to add a Node, please see our [nodes contribution guide](/development/guides/creating-nodes/).
+
+## Support and Triaging
+
+Helping support other users in [Discord](https://discord.gg/ZmtBAhwWhy) and on Github are valuable forms of contribution that we greatly appreciate.
+
+We receive many issues and requests for help from users. We're limited in bandwidth relative to our the user base, so providing answers to questions or helping identify causes of issues is very helpful. By doing this, you enable us to spend time on the highest priority work.
+
+## Documentation
+
+If you’d like to help with documentation, please see our [contributing guide](/contributing/).
+
+## Translation
+
+If you'd like to help with translation, please see our [translation guide](/contributing/translations/).
+
+## Tutorials
+
+Please reach out to @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI.
+
+## Contributors
+
+This project is a combined effort of dedicated people from across the world. [Check out the list of all these amazing people](/contributing/contributors/). We thank them for their time, hard work and effort.
+
+## Code of Conduct
+
+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:
+
+1. The contribution was created in whole or in part by you and you have the right to submit it under the open-source license indicated in this project’s GitHub repository; or
+2. The contribution is based upon previous work that, to the best of your knowledge, is covered under an appropriate open-source license and you have the right under that license to submit that work with modifications, whether created in whole or in part by you, under the same open-source license (unless you are permitted to submit under a different license); or
+3. The contribution was provided directly to you by some other person who certified (1) or (2) and you have not modified it; or
+4. You understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information you submit with it, including your sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open-source license(s) involved.
+
+This disclaimer is not a license and does not grant any rights or permissions. You must obtain necessary permissions and licenses, including from third parties, before contributing to this project.
+
+This disclaimer is provided "as is" without warranty of any kind, whether expressed or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, or non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the contribution or the use or other dealings in the contribution.
diff --git a/docs/src/content/docs/contributing/new-contributor-guide.mdx b/docs/src/content/docs/contributing/new-contributor-guide.mdx
new file mode 100644
index 00000000000..d5d29a36d0a
--- /dev/null
+++ b/docs/src/content/docs/contributing/new-contributor-guide.mdx
@@ -0,0 +1,105 @@
+---
+title: New Contributor Guide
+lastUpdated: 2026-02-19
+---
+
+import { Steps, LinkCard } from '@astrojs/starlight/components';
+
+If you're a new contributor to InvokeAI or Open Source Projects, this is the guide for you.
+
+## New Contributor Checklist
+
+
+ 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](/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/)
+
+ 4. Join the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord
+
+ 5. Choose an issue to work on! This can be achieved by asking in the #dev-chat channel, tackling a [good first issue](https://github.com/invoke-ai/InvokeAI/contribute) or finding an item on the [roadmap](https://github.com/orgs/invoke-ai/projects/7). If nothing in any of those places catches your eye, feel free to work on something of interest to you!
+
+ 6. Make your first Pull Request with the guide below
+
+ 7. Happy development! Don't be afraid to ask for help - we're happy to help you contribute!
+
+
+## How do I make a contribution?
+
+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](/development/setup/dev-environment/).
+
+
+ 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.
+
+ 2. Fork the [InvokeAI](https://github.com/invoke-ai/InvokeAI) repository to your GitHub profile. This means that you will have a copy of the repository under **your-GitHub-username/InvokeAI**.
+
+ 3. Clone the repository to your local machine using:
+
+ ```bash
+ git clone https://github.com/your-GitHub-username/InvokeAI.git
+ ```
+
+ If you're unfamiliar with using Git through the commandline, [GitHub Desktop](https://desktop.github.com) is a easy-to-use alternative with a UI. You can do all the same steps listed here, but through the interface. 4. Create a new branch for your fix using:
+
+ ```bash
+ git checkout -b branch-name-here
+ ```
+
+ 5. Make the appropriate changes for the issue you are trying to address or the feature that you want to add.
+
+ 6. Add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index:
+
+ ```bash
+ git add -A
+ ```
+
+ 7. Store the contents of the index with a descriptive message.
+
+ ```bash
+ git commit -m "Insert a short message of the changes made here"
+ ```
+
+ 8. Push the changes to the remote repository using
+
+ ```bash
+ git push origin branch-name-here
+ ```
+
+ 9. Submit a pull request to the **main** branch of the InvokeAI repository. If you're not sure how to, [follow this guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request)
+
+ 10. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #1234".
+
+ 11. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it!
+
+ 12. Wait for the pull request to be reviewed by other collaborators.
+
+ 13. Make changes to the pull request if the reviewer(s) recommend them.
+
+ 14. Celebrate your success after your pull request is merged!
+
+
+
+
+:::tip[Best Practices]
+
+- Keep your pull requests small. Smaller pull requests are more likely to be accepted and merged.
+- Comments! Commenting your code helps reviewers easily understand your contribution.
+- Use Python and Typescript’s typing systems, and consider using an editor with [LSP](https://microsoft.github.io/language-server-protocol/) support to streamline development.
+- Make all communications public. This ensure knowledge is shared with the whole community.
+
+:::
+
+## **Where can I go for help?**
+
+If you need help, you can ask questions in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord.
+
+For frontend related work, **@pyschedelicious** is the best person to reach out to.
+
+For backend related work, please reach out to **@blessedcoolant**, **@lstein**, **@StAlKeR7779** or **@pyschedelicious**.
diff --git a/docs/src/content/docs/contributing/translations.md b/docs/src/content/docs/contributing/translations.md
new file mode 100644
index 00000000000..53532312cb7
--- /dev/null
+++ b/docs/src/content/docs/contributing/translations.md
@@ -0,0 +1,21 @@
+---
+title: Translations
+---
+
+InvokeAI uses [Weblate](https://weblate.org/) for translation. Weblate is a FOSS project providing a scalable translation service. Weblate automates the tedious parts of managing translation of a growing project, and the service is generously provided at no cost to FOSS projects like InvokeAI.
+
+## Contributing
+
+If you'd like to contribute by adding or updating a translation, please visit our [Weblate project](https://hosted.weblate.org/engage/invokeai/). You'll need to sign in with your GitHub account (a number of other accounts are supported, including Google).
+
+Once signed in, select a language and then the Web UI component. From here you can Browse and Translate strings from English to your chosen language. Zen mode offers a simpler translation experience.
+
+Your changes will be attributed to you in the automated PR process; you don't need to do anything else.
+
+## Help & Questions
+
+Please check Weblate's [documentation](https://docs.weblate.org/en/latest/index.html) or ping @Harvestor on [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions.
+
+## Thanks
+
+Thanks to the InvokeAI community for their efforts to translate the project!
diff --git a/docs/assets/contributing/resize_invocation.png b/docs/src/content/docs/development/Architecture/assets/resize_invocation.png
similarity index 100%
rename from docs/assets/contributing/resize_invocation.png
rename to docs/src/content/docs/development/Architecture/assets/resize_invocation.png
diff --git a/docs/assets/contributing/resize_node_editor.png b/docs/src/content/docs/development/Architecture/assets/resize_node_editor.png
similarity index 100%
rename from docs/assets/contributing/resize_node_editor.png
rename to docs/src/content/docs/development/Architecture/assets/resize_node_editor.png
diff --git a/docs/src/content/docs/development/Architecture/invocations.mdx b/docs/src/content/docs/development/Architecture/invocations.mdx
new file mode 100644
index 00000000000..08ebdda6a93
--- /dev/null
+++ b/docs/src/content/docs/development/Architecture/invocations.mdx
@@ -0,0 +1,425 @@
+---
+title: Invocations
+lastUpdated: 2026-02-18
+---
+
+import { FileTree, Code, Steps } from '@astrojs/starlight/components'
+
+# Nodes
+
+Features in InvokeAI are added in the form of modular nodes systems called
+**Invocations**.
+
+An Invocation is simply a single operation that takes in some inputs and gives
+out some outputs. We can then chain multiple Invocations together to create more
+complex functionality.
+
+## Invocations Directory
+
+InvokeAI Nodes can be found in the `invokeai/app/invocations` directory. These
+can be used as examples to create your own nodes.
+
+New nodes should be added to a subfolder in `nodes` direction found at the root
+level of the InvokeAI installation location. Nodes added to this folder will be
+able to be used upon application startup.
+
+Example `nodes` subfolder structure:
+
+
+ - nodes
+ - `__init__.py` Invoke-managed custom node loader
+ - cool_node
+ - `__init__.py` see example below
+ - cool_node.py
+ - my_node_pack
+ - `__init__.py` see example below
+ - tasty_node.py
+ - bodacious_node.py
+ - utils.py
+ - extra_nodes
+ - fancy_node.py
+
+
+Each node folder must have an `__init__.py` file that imports its nodes. Only
+nodes imported in the `__init__.py` file are loaded. See the README in the nodes
+folder for more examples:
+
+```py title="__init__.py"
+from .cool_node import ResizeInvocation
+````
+
+## Creating A New Invocation
+
+In order to understand the process of creating a new Invocation, let us actually
+create one.
+
+In our example, let us create an Invocation that will take in an image, resize
+it and output the resized image.
+
+The first set of things we need to do when creating a new Invocation are -
+
+
+ 1. Create a new class that derives from a predefined parent class called `BaseInvocation`.
+ 2. Every Invocation must have a `docstring` that describes what this Invocation does.
+ 3. While not strictly required, we suggest every invocation class name ends in "Invocation", eg "CropImageInvocation".
+ 4. Every Invocation must use the `@invocation` decorator to provide its unique invocation type. You may provide its title, tags and category using the decorator.
+ 5. Invocations are strictly typed. We make use of the native [typing](https://docs.python.org/3/library/typing.html) library and the installed [pydantic](https://pydantic-docs.helpmanual.io/) library for validation.
+
+
+So let us do that.
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ invocation,
+)
+
+@invocation('resize')
+class ResizeInvocation(BaseInvocation):
+ '''Resizes an image'''
+```
+
+That's great.
+
+Now we have setup the base of our new Invocation. Let us think about what inputs
+our Invocation takes.
+
+- We need an `image` that we are going to resize.
+- We will need new `width` and `height` values to which we need to resize the
+ image to.
+
+### Inputs
+
+Every Invocation input must be defined using the `InputField` function. This is
+a wrapper around the pydantic `Field` function, which handles a few extra things
+and provides type hints. Like everything else, this should be strictly typed and
+defined.
+
+So let us create these inputs for our Invocation. First up, the `image` input we
+need. Generally, we can use standard variable types in Python but InvokeAI
+already has a custom `ImageField` type that handles all the stuff that is needed
+for image inputs.
+
+But what is this `ImageField` ..? It is a special class type specifically
+written to handle how images are dealt with in InvokeAI. We will cover how to
+create your own custom field types later in this guide. For now, let's go ahead
+and use it.
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ invocation,
+)
+
+@invocation('resize')
+class ResizeInvocation(BaseInvocation):
+
+ # Inputs
+ image: ImageField = InputField(description="The input image")
+```
+
+Let us break down our input code.
+
+```python
+image: ImageField = InputField(description="The input image")
+```
+
+| Part | Value | Description |
+| ---- | ----- | ----------- |
+| Name | `image` | The variable that will hold our image. |
+| Type Hint | `ImageField` | The type for our field. Indicates that `image` must be an `ImageField`. |
+| Field | `InputField(description="The input image")` | Declares `image` as an input field and provides its description. |
+
+Great. Now let us create our other inputs for `width` and `height`
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ invocation,
+)
+
+@invocation('resize')
+class ResizeInvocation(BaseInvocation):
+
+ # Inputs
+ image: ImageField = InputField(description="The input image")
+ width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
+ height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
+```
+
+As you might have noticed, we added two new arguments to the `InputField`
+definition for `width` and `height`, called `gt` and `le`. They stand for
+_greater than or equal to_ and _less than or equal to_.
+
+These impose constraints on those fields, and will raise an exception if the
+values do not meet the constraints. Field constraints are provided by
+**pydantic**, so anything you see in the **pydantic docs** will work.
+
+**Note:** _Any time it is possible to define constraints for our field, we
+should do it so the frontend has more information on how to parse this field._
+
+Perfect. We now have our inputs. Let us do something with these.
+
+### Invoke Function
+
+The `invoke` function is where all the magic happens. This function provides you
+the `context` parameter that is of the type `InvocationContext` which will give
+you access to the current context of the generation and all the other services
+that are provided by it by InvokeAI.
+
+Let us create this function first.
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ InvocationContext,
+ invocation,
+)
+
+@invocation('resize')
+class ResizeInvocation(BaseInvocation):
+ '''Resizes an image'''
+
+ image: ImageField = InputField(description="The input image")
+ width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
+ height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
+
+ def invoke(self, context: InvocationContext):
+ pass
+```
+
+### Outputs
+
+The output of our Invocation will be whatever is returned by this `invoke`
+function. Like with our inputs, we need to strongly type and define our outputs
+too.
+
+What is our output going to be? Another image. Normally you'd have to create a
+type for this but InvokeAI already offers you an `ImageOutput` type that handles
+all the necessary info related to image outputs. So let us use that.
+
+We will cover how to create your own output types later in this guide.
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ InvocationContext,
+ invocation,
+)
+
+from invokeai.app.invocations.image import ImageOutput
+
+@invocation('resize')
+class ResizeInvocation(BaseInvocation):
+ '''Resizes an image'''
+
+ image: ImageField = InputField(description="The input image")
+ width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
+ height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ pass
+```
+
+Perfect. Now that we have our Invocation setup, let us do what we want to do.
+
+- We will first load the image using one of the services provided by InvokeAI to
+ load the image.
+- We will resize the image using `PIL` to our input data.
+- We will output this image in the format we set above.
+
+So let's do that.
+
+```py title="resize.py"
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ InvocationContext,
+ invocation,
+)
+
+from invokeai.app.invocations.image import ImageOutput
+
+@invocation("resize")
+class ResizeInvocation(BaseInvocation):
+ """Resizes an image"""
+
+ image: ImageField = InputField(description="The input image")
+ width: int = InputField(default=512, ge=64, le=2048, description="Width of the new image")
+ height: int = InputField(default=512, ge=64, le=2048, description="Height of the new image")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ # Load the input image as a PIL image
+ image = context.images.get_pil(self.image.image_name)
+
+ # Resize the image
+ resized_image = image.resize((self.width, self.height))
+
+ # Save the image
+ image_dto = context.images.save(image=resized_image)
+
+ # Return an ImageOutput
+ return ImageOutput.build(image_dto)
+```
+
+**Note:** Do not be overwhelmed by the `ImageOutput` process. InvokeAI has a
+certain way that the images need to be dispatched in order to be stored and read
+correctly. In 99% of the cases when dealing with an image output, you can simply
+copy-paste the template above.
+
+### Customization
+
+We can use the `@invocation` decorator to provide some additional info to the
+UI, like a custom title, tags and category.
+
+We also encourage providing a version. This must be a
+[semver](https://semver.org/) version string ("`$MAJOR`.`$MINOR`.`$PATCH`"). The UI
+will let users know if their workflow is using a mismatched version of the node.
+
+```py title="resize.py"
+@invocation("resize", title="My Resizer", tags=["resize", "image"], category="My Invocations", version="1.0.0")
+class ResizeInvocation(BaseInvocation):
+ """Resizes an image"""
+
+ image: ImageField = InputField(description="The input image")
+
+ # Rest of the code
+```
+
+That's it. You made your own **Resize Invocation**.
+
+## Result
+
+Once you make your Invocation correctly, the rest of the process is fully
+automated for you.
+
+When you launch InvokeAI, you can go to `http://localhost:9090/docs` and see
+your new Invocation show up there with all the relevant info.
+
+
+
+When you launch the frontend UI, you can go to the Node Editor tab and find your
+new Invocation ready to be used.
+
+
+
+## Contributing Nodes
+
+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](/features/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
+
+### Custom Output Types
+
+Like with custom inputs, sometimes you might find yourself needing custom
+outputs that InvokeAI does not provide. We can easily set one up.
+
+Now that you are familiar with Invocations and Inputs, let us use that knowledge
+to create an output that has an `image` field, a `color` field and a `string`
+field.
+
+- An invocation output is a class that derives from the parent class of
+ `BaseInvocationOutput`.
+- All invocation outputs must use the `@invocation_output` decorator to provide
+ their unique output type.
+- Output fields must use the provided `OutputField` function. This is very
+ similar to the `InputField` function described earlier - it's a wrapper around
+ `pydantic`'s `Field()`.
+- It is not mandatory but we recommend using names ending with `Output` for
+ output types.
+- It is not mandatory but we highly recommend adding a `docstring` to describe
+ what your output type is for.
+
+Now that we know the basic rules for creating a new output type, let us go ahead
+and make it.
+
+```py title="custom_output.py"
+from .baseinvocation import BaseInvocationOutput, OutputField, invocation_output
+from .primitives import ImageField, ColorField
+
+@invocation_output('image_color_string_output')
+class ImageColorStringOutput(BaseInvocationOutput):
+ '''Base class for nodes that output a single image'''
+
+ image: ImageField = OutputField(description="The image")
+ color: ColorField = OutputField(description="The color")
+ text: str = OutputField(description="The string")
+```
+
+That's all there is to it.
+
+### Custom Input Fields
+
+Now that you know how to create your own Invocations, let us dive into slightly
+more advanced topics.
+
+While creating your own Invocations, you might run into a scenario where the
+existing fields in InvokeAI do not meet your requirements. In such cases, you
+can create your own fields.
+
+Let us create one as an example. Let us say we want to create a color input
+field that represents a color code. But before we start on that here are some
+general good practices to keep in mind.
+
+### Best Practices
+
+- There is no naming convention for input fields, but we highly recommend that
+ you name it something appropriate like `ColorField`.
+- It is not mandatory but it is heavily recommended to add a relevant
+ `docstring` to describe your field.
+- Keep your field in the same file as the Invocation that it is made for, or in
+ another file where it is relevant.
+
+All input types are a class that derive from the `BaseModel` type from `pydantic`.
+So let's create one.
+
+```py title="color_field.py"
+ from pydantic import BaseModel
+
+ class ColorField(BaseModel):
+ '''A field that holds the rgba values of a color'''
+ pass
+```
+
+Perfect. Now let us create the properties for our field. This is similar to how
+you created input fields for your Invocation. All the same rules apply. Let us
+create four fields representing the _red(r)_, _blue(b)_, _green(g)_ and
+_alpha(a)_ channel of the color.
+
+:::note
+ Technically, the properties are _also_ called fields - but in this case, it refers to a `pydantic` field.
+:::
+
+```py title="color_field.py"
+class ColorField(BaseModel):
+ '''A field that holds the rgba values of a color'''
+ r: int = Field(ge=0, le=255, description="The red channel")
+ g: int = Field(ge=0, le=255, description="The green channel")
+ b: int = Field(ge=0, le=255, description="The blue channel")
+ a: int = Field(ge=0, le=255, description="The alpha channel")
+```
+
+That's it. We now have a new input field type that we can use in our Invocations
+like this.
+
+```python
+color: ColorField = InputField(default=ColorField(r=0, g=0, b=0, a=0), description='Background color of an image')
+```
+
+### Using the custom field
+
+When you start the UI, your custom field will be automatically recognized.
+
+Custom fields only support connection inputs in the Workflow Editor.
diff --git a/docs/src/content/docs/development/Architecture/model-manager.mdx b/docs/src/content/docs/development/Architecture/model-manager.mdx
new file mode 100644
index 00000000000..ca7884482f6
--- /dev/null
+++ b/docs/src/content/docs/development/Architecture/model-manager.mdx
@@ -0,0 +1,1198 @@
+---
+title: Introduction to the Model Manager
+sidebar:
+ label: Model Manager
+lastUpdated: 2026-02-18
+---
+
+import { FileTree, Code, Steps } from '@astrojs/starlight/components';
+
+The Model Manager is responsible for organizing the various machine
+learning models used by InvokeAI. It consists of a series of
+interdependent services that together handle the full lifecycle of a
+model. These are the:
+
+- **ModelRecordServiceBase:** Responsible for managing model metadata and configuration information. Among other things, the record service tracks the type of the model, its provenance, and where it can be found on disk.
+- **ModelInstallServiceBase:** A service for installing models to disk. It uses `DownloadQueueServiceBase` to download models and their metadata, and `ModelRecordServiceBase` to store that information. It is also responsible for managing the InvokeAI `models` directory and its contents.
+- **DownloadQueueServiceBase:** A multithreaded downloader responsible for downloading models from a remote source to disk. The download queue has special methods for downloading repo_id folders from Hugging Face, as well as discriminating among model versions in Civitai, but can be used for arbitrary content.
+ - **ModelLoadServiceBase** Responsible for loading a model from disk into RAM and VRAM and getting it ready for inference.
+
+
+ ## Location of the Code
+
+ The four main services can be found in `invokeai/app/services` in the following directories:
+
+
+- invokeai
+ - app
+ - services Model manager services
+ - model_records/
+ - model_install/
+ - downloads/
+ - model_load/
+ - api
+ - routers
+ - model_manager_v2.py FastAPI web API for model management
+
+
+---
+
+## What's in a Model? The ModelRecordService
+
+The `ModelRecordService` manages the model's metadata. It supports a hierarchy of pydantic metadata "config" objects, which become increasingly specialized to support particular model types.
+
+### ModelConfigBase
+
+All model metadata classes inherit from this pydantic class. it provides the following fields:
+
+| **Field Name** | **Type** | **Description** |
+|----------------|-----------------|------------------|
+| `key` | str | Unique identifier for the model |
+| `name` | str | Name of the model (not unique) |
+| `model_type` | ModelType | The type of the model |
+| `model_format` | ModelFormat | The format of the model (e.g. "diffusers"); also used as a Union discriminator |
+| `base_model` | BaseModelType | The base model that the model is compatible with |
+| `path` | str | Location of model on disk |
+| `hash` | str | Hash of the model |
+| `description` | str | Human-readable description of the model (optional) |
+| `source` | str | Model's source URL or repo id (optional) |
+
+The `key` is a unique 32-character random ID which was generated at install time. The `hash` field stores a hash of the model's contents at install time obtained by sampling several parts of the model's files using the `imohash` library. Over the course of the model's lifetime it may be transformed in various ways, such as changing its precision or converting it from a .safetensors to a diffusers model.
+
+The `path` field can be absolute or relative. If relative, it is taken to be relative to the `models_dir` setting in the user's `invokeai.yaml` file.
+
+`ModelType`, `ModelFormat` and `BaseModelType` are string enums that are defined in `invokeai.backend.model_manager.config`. They are also imported by, and can be reexported from, `invokeai.app.services.model_manager.model_records`:
+
+```py title="invokeai.backend.model_manager.config"
+from invokeai.app.services.model_records import ModelType, ModelFormat, BaseModelType
+````
+
+### CheckpointConfig
+
+This adds support for checkpoint configurations, and adds the
+following field:
+
+| **Field Name** | **Type** | **Description** |
+|----------------|-----------------|------------------|
+| `config` | str | Path to the checkpoint's config file |
+
+`config` is the path to the checkpoint's config file. If relative, it is taken to be relative to the InvokeAI root directory (e.g. `configs/stable-diffusion/v1-inference.yaml`)
+
+### MainConfig
+
+This adds support for "main" Stable Diffusion models, and adds these fields:
+
+| **Field Name** | **Type** | **Description** |
+|----------------|-----------------|------------------|
+| `vae` | str | Path to a VAE to use instead of the burnt-in one |
+| `variant` | ModelVariantType| Model variant type, such as "inpainting" |
+
+`vae` can be an absolute or relative path. If relative, its base is taken to be the `models_dir` directory.
+
+`variant` is an enumerated string class with values `normal`, `inpaint` and `depth`. If needed, it can be imported if needed from either `invokeai.app.services.model_records` or `invokeai.backend.model_manager.config`.
+
+### ONNXSD2Config
+
+| **Field Name** | **Type** | **Description** |
+|----------------|-----------------|------------------|
+| `prediction_type` | SchedulerPredictionType | Scheduler prediction type to use, e.g. "epsilon" |
+| `upcast_attention` | bool | Model requires its attention module to be upcast |
+
+The `SchedulerPredictionType` enum can be imported from either `invokeai.app.services.model_records` or `invokeai.backend.model_manager.config`.
+
+### Other config classes
+
+There are a series of such classes each discriminated by their `ModelFormat`, including `LoRAConfig`, `IPAdapterConfig`, and so forth. These are rarely needed outside the model manager's internal code, but available in `invokeai.backend.model_manager.config` if needed. There is also a Union of all ModelConfig classes, called `AnyModelConfig` that can be imported from the same file.
+
+### Limitations of the Data Model
+
+The config hierarchy has a major limitation in its handling of the base model type. Each model can only be compatible with one base model, which breaks down in the event of models that are compatible with two or more base models. For example, SD-1 VAEs also work with SD-2 models. A partial workaround is to use `BaseModelType.Any`, which indicates that the model is compatible with any of the base models. This works OK for some models, such as the IP Adapter image encoders, but is an all-or-nothing proposition.
+
+## Reading and Writing Model Configuration Records
+
+The `ModelRecordService` provides the ability to retrieve model configuration records from SQL or YAML databases, update them, and write them back.
+
+A application-wide `ModelRecordService` is created during API initialization and can be retrieved within an invocation from the `InvocationContext` object:
+
+```py
+store = context.services.model_manager.store
+```
+
+or from elsewhere in the code by accessing `ApiDependencies.invoker.services.model_manager.store`.
+
+### Creating a `ModelRecordService`
+
+To create a new `ModelRecordService` database or open an existing one, you can directly create either a `ModelRecordServiceSQL` or a `ModelRecordServiceFile` object:
+
+```py
+from invokeai.app.services.model_records import ModelRecordServiceSQL, ModelRecordServiceFile
+
+store = ModelRecordServiceSQL.from_connection(connection, lock)
+store = ModelRecordServiceSQL.from_db_file('/path/to/sqlite_database.db')
+store = ModelRecordServiceFile.from_db_file('/path/to/database.yaml')
+```
+
+The `from_connection()` form is only available from the `ModelRecordServiceSQL` class, and is used to manage records in a previously-opened SQLITE3 database using a `sqlite3.connection` object and a `threading.lock` object. It is intended for the specific use case of storing the record information in the main InvokeAI database, usually `databases/invokeai.db`.
+
+The `from_db_file()` methods can be used to open new connections to the named database files. If the file doesn't exist, it will be created and initialized.
+
+As a convenience, `ModelRecordServiceBase` offers two methods, `from_db_file` and `open`, which will return either a SQL or File implementation depending on the context. The former looks at the file extension to determine whether to open the file as a SQL database (".db") or as a file database (".yaml"). If the file exists, but is either the wrong type or does not contain the expected schema metainformation, then an appropriate `AssertionError` will be raised:
+
+```py
+store = ModelRecordServiceBase.from_db_file('/path/to/a/file.{yaml,db}')
+```
+
+The `ModelRecordServiceBase.open()` method is specifically designed for use in the InvokeAI web server. Its signature is:
+
+```py
+def open(
+ cls,
+ config: InvokeAIAppConfig,
+ conn: Optional[sqlite3.Connection] = None,
+ lock: Optional[threading.Lock] = None
+ ) -> Union[ModelRecordServiceSQL, ModelRecordServiceFile]:
+```
+
+The way it works is as follows:
+
+1. Retrieve the value of the `model_config_db` option from the user's `invokeai.yaml` config file.
+2. If `model_config_db` is `auto` (the default), then:
+ - Use the values of `conn` and `lock` to return a `ModelRecordServiceSQL` object opened on the passed connection and lock.
+ - Open up a new connection to `databases/invokeai.db` if `conn` and/or `lock` are missing (see note below).
+3. If `model_config_db` is a Path, then use `from_db_file` to return the appropriate type of ModelRecordService.
+4. If `model_config_db` is None, then retrieve the legacy `conf_path` option from `invokeai.yaml` and use the Path indicated there. This will default to `configs/models.yaml`.
+
+So a typical startup pattern would be:
+
+```py
+import sqlite3
+from invokeai.app.services.thread import lock
+from invokeai.app.services.model_records import ModelRecordServiceBase
+from invokeai.app.services.config import InvokeAIAppConfig
+
+config = InvokeAIAppConfig.get_config()
+db_conn = sqlite3.connect(config.db_path.as_posix(), check_same_thread=False)
+store = ModelRecordServiceBase.open(config, db_conn, lock)
+```
+
+### Fetching a Model's Configuration from `ModelRecordServiceBase`
+
+Configurations can be retrieved in several ways.
+
+#### get_model(key) -> AnyModelConfig
+
+The basic functionality is to call the record store object's `get_model()` method with the desired model's unique key. It returns the appropriate subclass of ModelConfigBase:
+
+```py
+model_conf = store.get_model('f13dd932c0c35c22dcb8d6cda4203764')
+print(model_conf.path)
+
+>> '/tmp/models/ckpts/v1-5-pruned-emaonly.safetensors'
+
+```
+
+If the key is unrecognized, this call raises an `UnknownModelException`.
+
+#### exists(key) -> AnyModelConfig
+
+Returns True if a model with the given key exists in the database.
+
+#### search_by_path(path) -> AnyModelConfig
+
+Returns the configuration of the model whose path is `path`. The path is matched using a simple string comparison and won't correctly match models referred to by different paths (e.g. using symbolic links).
+
+#### search_by_name(name, base, type) -> List[AnyModelConfig]
+
+This method searches for models that match some combination of `name`, `BaseType` and `ModelType`. Calling without any arguments will return all the models in the database.
+
+#### all_models() -> List[AnyModelConfig]
+
+Return all the model configs in the database. Exactly equivalent to calling `search_by_name()` with no arguments.
+
+#### search_by_tag(tags) -> List[AnyModelConfig]
+
+`tags` is a list of strings. This method returns a list of model configs that contain all of the given tags. Examples:
+
+```py
+# find all models that are marked as both SFW and as generating
+# background scenery
+configs = store.search_by_tag(['sfw', 'scenery'])
+```
+
+Note that only tags are not searchable in this way. Other fields can be searched using a filter:
+
+```py
+commercializable_models = [x for x in store.all_models() \
+ if x.license.contains('allowCommercialUse=Sell')]
+```
+
+#### version() -> str
+
+Returns the version of the database, currently at `3.2`
+
+#### model_info_by_name(name, base_model, model_type) -> ModelConfigBase
+
+This method exists to ease the transition from the previous version of the model manager, in which `get_model()` took the three arguments shown above. This looks for a unique model identified by name, base model and model type and returns it.
+
+The method will generate a `DuplicateModelException` if there are more than one models that share the same type, base and name. While unlikely, it is certainly possible to have a situation in which the user had added two models with the same name, base and type, one located at path `/foo/my_model` and the other at `/bar/my_model`. It is strongly recommended to search for models using `search_by_name()`, which can return multiple results, and then to select the desired model and pass its key to `get_model()`.
+
+### Writing model configs to the database
+
+Several methods allow you to create and update stored model config records.
+
+#### add_model(key, config) -> AnyModelConfig
+
+Given a key and a configuration, this will add the model's configuration record to the database. `config` can either be a subclass of `ModelConfigBase` (i.e. any class listed in `AnyModelConfig`), or a `dict` of key/value pairs. In the latter case, the correct configuration class will be picked by Pydantic's discriminated union mechanism.
+
+If successful, the method will return the appropriate subclass of `ModelConfigBase`. It will raise a `DuplicateModelException` if a model with the same key is already in the database, or an `InvalidModelConfigException` if a dict was passed and Pydantic experienced a parse or validation error.
+
+### update_model(key, config) -> AnyModelConfig
+
+Given a key and a configuration, this will update the model configuration record in the database. `config` can be either a instance of `ModelConfigBase`, or a sparse `dict` containing the fields to be updated. This will return an `AnyModelConfig` on success, or raise `InvalidModelConfigException` or `UnknownModelException` exceptions on failure.
+
+---
+
+## Model installation
+
+The `ModelInstallService` class implements the
+`ModelInstallServiceBase` abstract base class, and provides a one-stop
+shop for all your model install needs. It provides the following
+functionality:
+
+- Registering a model config record for a model already located on the local filesystem, without moving it or changing its path.
+
+- Installing a model alreadiy located on the local filesystem, by moving it into the InvokeAI root directory under the `models` folder (or wherever config parameter `models_dir` specifies).
+
+- Probing of models to determine their type, base type and other key information.
+
+- Interface with the InvokeAI event bus to provide status updates on the download, installation and registration process.
+
+- Downloading a model from an arbitrary URL and installing it in `models_dir`.
+
+- Special handling for HuggingFace repo_ids to recursively download the contents of the repository, paying attention to alternative variants such as fp16.
+
+- Saving tags and other metadata about the model into the invokeai database when fetching from a repo that provides that type of information, (currently only HuggingFace).
+
+### Initializing the installer
+
+A default installer is created at InvokeAI api startup time and stored in `ApiDependencies.invoker.services.model_install` and can also be retrieved from an invocation's `context` argument with `context.services.model_install`.
+
+In the event you wish to create a new installer, you may use the following initialization pattern:
+
+```py
+from invokeai.app.services.config import get_config
+from invokeai.app.services.model_records import ModelRecordServiceSQL
+from invokeai.app.services.model_install import ModelInstallService
+from invokeai.app.services.download import DownloadQueueService
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.backend.util.logging import InvokeAILogger
+
+config = get_config()
+
+logger = InvokeAILogger.get_logger(config=config)
+db = SqliteDatabase(config.db_path, logger)
+record_store = ModelRecordServiceSQL(db, logger)
+queue = DownloadQueueService()
+queue.start()
+
+installer = ModelInstallService(app_config=config,
+ record_store=record_store,
+ download_queue=queue
+ )
+installer.start()
+```
+
+The full form of `ModelInstallService()` takes the following required parameters:
+
+| **Argument** | **Type** | **Description** |
+|------------------|------------------------------|------------------------------|
+| `app_config` | InvokeAIAppConfig | InvokeAI app configuration object |
+| `record_store` | ModelRecordServiceBase | Config record storage database |
+| `download_queue` | DownloadQueueServiceBase | Download queue object |
+|`session` | Optional[requests.Session] | Swap in a different Session object (usually for debugging) |
+
+Once initialized, the installer will provide the following methods:
+
+#### install_job = installer.heuristic_import(source, [config], [access_token])
+
+This is a simplified interface to the installer which takes a source string, an optional model configuration dictionary and an optional access token.
+
+The `source` is a string that can be any of these forms
+
+1. A path on the local filesystem (`C:\\users\\fred\\model.safetensors`)
+2. A Url pointing to a single downloadable model file (`https://civitai.com/models/58390/detail-tweaker-lora-lora`)
+3. A HuggingFace repo_id with any of the following formats:
+ * `model/name` -- entire model
+ * `model/name:fp32` -- entire model, using the fp32 variant
+ * `model/name:fp16:vae` -- vae submodel, using the fp16 variant
+ * `model/name::vae` -- vae submodel, using default precision
+ * `model/name:fp16:path/to/model.safetensors` -- an individual model file, fp16 variant
+ * `model/name::path/to/model.safetensors` -- an individual model file, default variant
+
+Note that by specifying a relative path to the top of the HuggingFace repo, you can download and install arbitrary models files.
+
+The variant, if not provided, will be automatically filled in with `fp32` if the user has requested full precision, and `fp16` otherwise. If a variant that does not exist is requested, then the method will install whatever HuggingFace returns as its default revision.
+
+`config` is an optional dict of values that will override the autoprobed values for model type, base, scheduler prediction type, and so forth. See [Model configuration and probing](#model-configuration-and-probing) for details.
+
+`access_token` is an optional access token for accessing resources that need authentication.
+
+The method will return a `ModelInstallJob`. This object is discussed at length in the following section.
+
+#### install_job = installer.import_model()
+
+The `import_model()` method is the core of the installer. The following illustrates basic usage:
+
+```py
+from invokeai.app.services.model_install import (
+ LocalModelSource,
+ HFModelSource,
+ URLModelSource,
+)
+
+source1 = LocalModelSource(path='/opt/models/sushi.safetensors') # a local safetensors file
+source2 = LocalModelSource(path='/opt/models/sushi_diffusers') # a local diffusers folder
+
+source3 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5') # a repo_id
+source4 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', subfolder='vae') # a subfolder within a repo_id
+source5 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', variant='fp16') # a named variant of a HF model
+source6 = HFModelSource(repo_id='runwayml/stable-diffusion-v1-5', subfolder='OrangeMix/OrangeMix1.ckpt') # path to an individual model file
+
+source7 = URLModelSource(url='https://civitai.com/api/download/models/63006') # model located at a URL
+source8 = URLModelSource(url='https://civitai.com/api/download/models/63006', access_token='letmein') # with an access token
+
+for source in [source1, source2, source3, source4, source5, source6, source7]:
+ install_job = installer.install_model(source)
+
+source2job = installer.wait_for_installs(timeout=120)
+for source in sources:
+ job = source2job[source]
+ if job.complete:
+ model_config = job.config_out
+ model_key = model_config.key
+ print(f"{source} installed as {model_key}")
+ elif job.errored:
+ print(f"{source}: {job.error_type}.\nStack trace:\n{job.error}")
+
+```
+
+As shown here, the `import_model()` method accepts a variety of sources, including local safetensors files, local diffusers folders, HuggingFace repo_ids with and without a subfolder designation, Civitai model URLs and arbitrary URLs that point to checkpoint files (but not to folders).
+
+Each call to `import_model()` return a `ModelInstallJob` job, an object which tracks the progress of the install.
+
+If a remote model is requested, the model's files are downloaded in parallel across a multiple set of threads using the download queue. During the download process, the `ModelInstallJob` is updated to provide status and progress information. After the files (if any) are downloaded, the remainder of the installation runs in a single serialized background thread. These are the model probing, file copying, and config record database update steps.
+
+Multiple install jobs can be queued up. You may block until all install jobs are completed (or errored) by calling the `wait_for_installs()` method as shown in the code example. `wait_for_installs()` will return a `dict` that maps the requested source to its job. This object can be interrogated to determine its status. If the job errored out, then the error type and details can be recovered from `job.error_type` and `job.error`.
+
+The full list of arguments to `import_model()` is as follows:
+
+| Argument | Type | Default | Description |
+|----------|-------|---------|-------------|
+| `source` | ModelSource | None | The source of the model, Path, URL or repo_id |
+| `config` | Dict[str, Any] | None | Override all or a portion of model's probed attributes |
+
+The next few sections describe the various types of ModelSource that can be passed to `import_model()`.
+
+`config` can be used to override all or a portion of the configuration attributes returned by the model prober. See the section below for details.
+
+#### LocalModelSource
+
+This is used for a model that is located on a locally-accessible Posix filesystem, such as a local disk or networked fileshare.
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `path` | str | Path | None | Path to the model file or directory |
+| `inplace` | bool | False | If set, the model file(s) will be left in their location; otherwise they will be copied into the InvokeAI root's `models` directory |
+
+#### URLModelSource
+
+This is used for a single-file model that is accessible via a URL. The
+fields are:
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `url` | AnyHttpUrl | None | The URL for the model file. |
+| `access_token` | str | None | An access token needed to gain access to this file. |
+
+The `AnyHttpUrl` class can be imported from `pydantic.networks`.
+
+Ordinarily, no metadata is retrieved from these sources. However, there is special-case code in the installer that looks for HuggingFace and fetches the corresponding model metadata from the corresponding repo.
+
+#### HFModelSource
+
+HuggingFace has the most complicated `ModelSource` structure:
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `repo_id` | str | None | The ID of the desired model. |
+| `variant` | ModelRepoVariant | ModelRepoVariant('fp16') | The desired variant. |
+| `subfolder` | Path | None | Look for the model in a subfolder of the repo. |
+| `access_token` | str | None | An access token needed to gain access to a subscriber's-only model. |
+
+The `repo_id` is the repository ID, such as `stabilityai/sdxl-turbo`.
+
+The `variant` is one of the various diffusers formats that HuggingFace supports and is used to pick out from the hodgepodge of files that in a typical HuggingFace repository the particular components needed for a complete diffusers model. `ModelRepoVariant` is an enum that can be imported from `invokeai.backend.model_manager` and has the following values:
+
+| Name | String Value |
+|------|--------------|
+| ModelRepoVariant.DEFAULT | "default" |
+| ModelRepoVariant.FP16 | "fp16" |
+| ModelRepoVariant.FP32 | "fp32" |
+| ModelRepoVariant.ONNX | "onnx" |
+| ModelRepoVariant.OPENVINO | "openvino" |
+| ModelRepoVariant.FLAX | "flax" |
+
+You can also pass the string forms to `variant` directly. Note that InvokeAI may not be able to load and run all variants. At the current time, specifying `ModelRepoVariant.DEFAULT` will retrieve model files that are unqualified, e.g. `pytorch_model.safetensors` rather than `pytorch_model.fp16.safetensors`. These are usually the 32-bit safetensors forms of the model.
+
+If `subfolder` is specified, then the requested model resides in a subfolder of the main model repository. This is typically used to fetch and install VAEs.
+
+Some models require you to be registered with HuggingFace and logged in. To download these files, you must provide an `access_token`. Internally, if no access token is provided, then `HfFolder.get_token()` will be called to fill it in with the cached one.
+
+#### Monitoring the install job process
+
+When you create an install job with `import_model()`, it launches the download and installation process in the background and returns a `ModelInstallJob` object for monitoring the process.
+
+The `ModelInstallJob` class has the following structure:
+
+| Attribute | Type | Description |
+|-----------|------|-------------|
+| `id` | `int` | Integer ID for this job |
+| `status` | `InstallStatus` | An enum of [`waiting`, `downloading`, `running`, `completed`, `error` and `cancelled`] |
+| `config_in` | `dict` | Overriding configuration values provided by the caller |
+| `config_out` | `AnyModelConfig` | After successful completion, contains the configuration record written to the database |
+| `inplace` | `boolean` | True if the caller asked to install the model in place using its local path |
+| `source` | `ModelSource` | The local path, remote URL or repo_id of the model to be installed |
+| `local_path` | `Path` | If a remote model, holds the path of the model after it is downloaded; if a local model, same as `source` |
+| `error_type` | `str` | Name of the exception that led to an error status |
+| `error` | `str` | Traceback of the error |
+
+If the `event_bus` argument was provided, events will also be broadcast to the InvokeAI event bus. The events will appear on the bus as an event of type `EventServiceBase.model_event`, a timestamp and the following event names:
+
+##### `model_install_downloading`
+
+For remote models only, `model_install_downloading` events will be issued at regular intervals as the download progresses. The event's payload contains the following keys:
+
+| Key | Type | Description |
+|-----|------|-------------|
+|`source`|str|String representation of the requested source|
+|`local_path`|str|String representation of the path to the downloading model (usually a temporary directory)|
+|`bytes`|int|How many bytes downloaded so far|
+|`total_bytes`|int|Total size of all the files that make up the model|
+|`parts`|List[Dict]|Information on the progress of the individual files that make up the model|
+
+The parts is a list of dictionaries that give information on each of the components pieces of the download. The dictionary's keys are `source`, `local_path`, `bytes` and `total_bytes`, and correspond to the like-named keys in the main event.
+
+Note that downloading events will not be issued for local models, and that downloading events occur _before_ the running event.
+
+##### `model_install_running`
+
+`model_install_running` is issued when all the required downloads have completed (if applicable) and the model probing, copying and registration process has now started.
+
+The payload will contain the key `source`.
+
+##### `model_install_completed`
+
+`model_install_completed` is issued once at the end of a successful installation. The payload will contain the keys `source`, `total_bytes` and `key`, where `key` is the ID under which the model has been registered.
+
+##### `model_install_error`
+
+`model_install_error` is emitted if the installation process fails for some reason. The payload will contain the keys `source`, `error_type` and `error`. `error_type` is a short message indicating the nature of the error, and `error` is the long traceback to help debug the problem.
+
+##### `model_install_cancelled`
+
+`model_install_cancelled` is issued if the model installation is cancelled, or if one or more of its files' downloads are cancelled. The payload will contain `source`.
+
+##### Following the model status
+
+You may poll the `ModelInstallJob` object returned by `import_model()` to ascertain the state of the install. The job status can be read from the job's `status` attribute, an `InstallStatus` enum which has the enumerated values `WAITING`, `DOWNLOADING`, `RUNNING`, `COMPLETED`, `ERROR` and `CANCELLED`.
+
+For convenience, install jobs also provided the following boolean properties: `waiting`, `downloading`, `running`, `complete`, `errored` and `cancelled`, as well as `in_terminal_state`. The last will return True if the job is in the complete, errored or cancelled states.
+
+#### Model configuration and probing
+
+The install service uses the `invokeai.backend.model_manager.probe` module during import to determine the model's type, base type, and other configuration parameters. Among other things, it assigns a default name and description for the model based on probed fields.
+
+When downloading remote models is implemented, additional configuration information, such as list of trigger terms, will be retrieved from the HuggingFace and Civitai model repositories.
+
+The probed values can be overridden by providing a dictionary in the optional `config` argument passed to `import_model()`. You may provide overriding values for any of the model's configuration attributes. Here is an example of setting the `SchedulerPredictionType` and `name` for an sd-2 model:
+
+```py
+install_job = installer.import_model(
+ source=HFModelSource(repo_id='stabilityai/stable-diffusion-2-1',variant='fp32'),
+ config=dict(
+ prediction_type=SchedulerPredictionType('v_prediction')
+ name='stable diffusion 2 base model',
+ )
+ )
+```
+
+### Other installer methods
+
+This section describes additional methods provided by the installer class.
+
+#### jobs = installer.wait_for_installs([timeout])
+
+Block until all pending installs are completed or errored and then returns a list of completed jobs. The optional `timeout` argument will return from the call if jobs aren't completed in the specified time. An argument of 0 (the default) will block indefinitely.
+
+#### jobs = installer.wait_for_job(job, [timeout])
+
+Like `wait_for_installs()`, but block until a specific job has completed or errored, and then return the job. The optional `timeout` argument will return from the call if the job doesn't complete in the specified time. An argument of 0 (the default) will block indefinitely.
+
+#### jobs = installer.list_jobs()
+
+Return a list of all active and complete `ModelInstallJobs`.
+
+#### jobs = installer.get_job_by_source(source)
+
+Return a list of `ModelInstallJob` corresponding to the indicated model source.
+
+#### jobs = installer.get_job_by_id(id)
+
+Return a list of `ModelInstallJob` corresponding to the indicated model id.
+
+#### jobs = installer.cancel_job(job)
+
+Cancel the indicated job.
+
+#### installer.prune_jobs
+
+Remove jobs that are in a terminal state (i.e. complete, errored or cancelled) from the job list returned by `list_jobs()` and `get_job()`.
+
+#### installer.app_config, installer.record_store, installer.event_bus
+
+Properties that provide access to the installer's `InvokeAIAppConfig`, `ModelRecordServiceBase` and `EventServiceBase` objects.
+
+#### key = installer.register_path(model_path, config), key = installer.install_path(model_path, config)
+
+These methods bypass the download queue and directly register or install the model at the indicated path, returning the unique ID for the installed model.
+
+Both methods accept a Path object corresponding to a checkpoint or diffusers folder, and an optional dict of config attributes to use to override the values derived from model probing.
+
+The difference between `register_path()` and `install_path()` is that the former creates a model configuration record without changing the location of the model in the filesystem. The latter makes a copy of the model inside the InvokeAI models directory before registering it.
+
+#### installer.unregister(key)
+
+This will remove the model config record for the model at key, and is equivalent to `installer.record_store.del_model(key)`
+
+#### installer.delete(key)
+
+This is similar to `unregister()` but has the additional effect of conditionally deleting the underlying model file(s) if they reside within the InvokeAI models directory
+
+#### installer.unconditionally_delete(key)
+
+This method is similar to `unregister()`, but also unconditionally deletes the corresponding model weights file(s), regardless of whether they are inside or outside the InvokeAI models hierarchy.
+
+#### path = installer.download_and_cache(remote_source, [access_token], [timeout])
+
+This utility routine will download the model file located at source, cache it, and return the path to the cached file. It does not attempt to determine the model type, probe its configuration values, or register it with the models database.
+
+You may provide an access token if the remote source requires authorization. The call will block indefinitely until the file is completely downloaded, cancelled or raises an error of some sort. If you provide a timeout (in seconds), the call will raise a `TimeoutError` exception if the download hasn't completed in the specified period.
+
+You may use this mechanism to request any type of file, not just a model. The file will be stored in a subdirectory of `INVOKEAI_ROOT/models/.cache`. If the requested file is found in the cache, its path will be returned without redownloading it.
+
+Be aware that the models cache is cleared of infrequently-used files and directories at regular intervals when the size of the cache exceeds the value specified in Invoke's `convert_cache` configuration variable.
+
+#### installer.start(invoker)
+
+The `start` method is called by the API initialization routines when the API starts up. Its effect is to call `sync_to_config()` to synchronize the model record store database with what's currently on disk.
+
+---
+
+## Get on line: The Download Queue
+
+InvokeAI can download arbitrary files using a multithreaded background download queue. Internally, the download queue is used for installing models located at remote locations. The queue is implemented by the `DownloadQueueService` defined in `invokeai.app.services.download_manager`. However, most of the implementation is spread out among several files in `invokeai/backend/model_manager/download/*`
+
+A default download queue is located in `ApiDependencies.invoker.services.download_queue`. However, you can create additional instances if you need to isolate your queue from the main one.
+
+### A job for every task
+
+The queue operates on a series of download job objects. These objects specify the source and destination of the download, and keep track of the progress of the download. Jobs come in a variety of shapes and colors as they are progressively specialized for particular download task.
+
+The basic job is the `DownloadJobBase`, a pydantic object with the following fields:
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `id` | int | | Job ID, an integer >= 0 |
+| `priority` | int | 10 | Job priority. Lower priorities run before higher priorities |
+| `source` | str | | Where to download from (specialized types used in subclasses) |
+| `destination` | Path | | Where to download to |
+| `status` | DownloadJobStatus | Idle | Job's status (see below) |
+| `event_handlers` | List[DownloadEventHandler] | | Event handlers (see below) |
+| `job_started` | float | | Timestamp for when the job started running |
+| `job_ended` | float | | Timestamp for when the job completed or errored out |
+| `job_sequence` | int | | A counter that is incremented each time a model is dequeued |
+| `error` | Exception | | A copy of the Exception that caused an error during download |
+
+When you create a job, you can assign it a `priority`. If multiple jobs are queued, the job with the lowest priority runs first. (Don't blame us! The Unix developers came up with this convention.)
+
+Every job has a `source` and a `destination`. `source` is a string in the base class, but subclassses redefine it more specifically.
+
+The `destination` must be the Path to a file or directory on the local filesystem. If the Path points to a new or existing file, then the source will be stored under that filename. If the Path ponts to an existing directory, then the downloaded file will be stored inside the directory, usually using the name assigned to it at the remote site in the `content-disposition` http field.
+
+When the job is submitted, it is assigned a numeric `id`. The id can then be used to control the job, such as starting, stopping and cancelling its download.
+
+The `status` field is updated by the queue to indicate where the job is in its lifecycle. Values are defined in the string enum `DownloadJobStatus`, a symbol available from `invokeai.app.services.download_manager`. Possible values are:
+
+| Value | String Value | Description |
+|-------|--------------|-------------|
+| `IDLE` | idle | Job created, but not submitted to the queue |
+| `ENQUEUED` | enqueued | Job is patiently waiting on the queue |
+| `RUNNING` | running | Job is running! |
+| `PAUSED` | paused | Job was paused and can be restarted |
+| `COMPLETED` | completed | Job has finished its work without an error |
+| `ERROR` | error | Job encountered an error and will not run again |
+| `CANCELLED` | cancelled | Job was cancelled and will not run (again) |
+
+`job_started`, `job_ended` and `job_sequence` indicate when the job was started (using a python timestamp), when it completed, and the order in which it was taken off the queue. These are mostly used for debugging and performance testing.
+
+In case of an error, the Exception that caused the error will be placed in the `error` field, and the job's status will be set to `DownloadJobStatus.ERROR`.
+
+After an error occurs, any partially downloaded files will be deleted from disk, unless `preserve_partial_downloads` was set to True at job creation time (or set to True any time before the error occurred). Note that since all InvokeAI model install operations involve downloading files to a temporary directory that has a limited lifetime, this flag is not used by the model installer.
+
+There are a series of subclasses of `DownloadJobBase` that provide support for specific types of downloads. These are:
+
+#### DownloadJobPath
+
+This subclass redefines `source` to be a filesystem Path. It is used to move a file or directory from the `source` to the `destination` paths in the background using a uniform event-based infrastructure.
+
+#### DownloadJobRemoteSource
+
+This subclass adds the following fields to the job:
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `bytes` | int | 0 | bytes downloaded so far |
+| `total_bytes` | int | 0 | total size to download |
+| `access_token` | Any | None | an authorization token to present to the remote source |
+
+The job will start out with 0/0 in its bytes/total_bytes fields. Once it starts running, `total_bytes` will be populated from information provided in the HTTP download header (if available), and the number of bytes downloaded so far will be progressively incremented.
+
+#### DownloadJobURL
+
+This is a subclass of `DownloadJobBase`. It redefines `source` to be a
+Pydantic `AnyHttpUrl` object, which enforces URL validation checking
+on the field.
+
+Note that the installer service defines an additional subclass of
+`DownloadJobRemoteSource` that accepts HuggingFace repo_ids in
+addition to URLs. This is discussed later in this document.
+
+### Event handlers
+
+While a job is being downloaded, the queue will emit events at
+periodic intervals. A typical series of events during a successful
+download session will look like this:
+
+
+ 1. `enqueued`
+ 2. `running`
+ 3. `running`
+ 4. `running`
+ 5. `completed`
+
+
+There will be a single enqueued event, followed by one or more running events, and finally one `completed`, `error` or `cancelled` events.
+
+It is possible for a caller to pause download temporarily, in which case the events may look something like this:
+
+
+ 1. `enqueued`
+ 2. `running`
+ 3. `running`
+ 4. `paused` user paused the download
+ 5. `running`
+ 6. `completed`
+
+
+The download queue logs when downloads start and end (unless `quiet` is set to True at initialization time) but doesn't log any progress events. You will probably want to be alerted to events during the download job and provide more user feedback. In order to intercept and respond to events you may install a series of one or more event handlers in the job. Whenever the job's status changes, the chain of event handlers is traversed and executed in the same thread that the download job is running in.
+
+Event handlers have the signature `Callable[["DownloadJobBase"], None]`, i.e.
+
+```py
+def handler(job: DownloadJobBase):
+ pass
+```
+
+A typical handler will examine `job.status` and decide if there's something to be done. This can include cancelling or erroring the job, but more typically is used to report on the job status to the user interface or to perform certain actions on successful completion of the job.
+
+Event handlers can be attached to a job at creation time. In addition, you can create a series of default handlers that are attached to the queue object itself. These handlers will be executed for each job after the job's own handlers (if any) have run.
+
+During a download, running events are issued every time roughly 1% of the file is transferred. This is to provide just enough granularity to update a tqdm progress bar smoothly.
+
+Handlers can be added to a job after the fact using the job's `add_event_handler` method:
+
+```py
+job.add_event_handler(my_handler)
+```
+
+All handlers can be cleared using the job's `clear_event_handlers()` method. Note that it might be a good idea to pause the job before altering its handlers.
+
+### Creating a download queue object
+
+The `DownloadQueueService` constructor takes the following arguments:
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `event_handlers` | List[DownloadEventHandler] | [] | Event handlers |
+| `max_parallel_dl` | int | 5 | Maximum number of simultaneous downloads allowed |
+| `requests_session` | requests.sessions.Session | None | An alternative requests Session object to use for the download |
+| `quiet` | bool | False | Do work quietly without issuing log messages |
+
+A typical initialization sequence will look like:
+
+```py
+from invokeai.app.services.download_manager import DownloadQueueService
+
+def log_download_event(job: DownloadJobBase):
+ logger.info(f'job={job.id}: status={job.status}')
+
+queue = DownloadQueueService(
+ event_handlers=[log_download_event]
+ )
+```
+
+Event handlers can be provided to the queue at initialization time as shown in the example. These will be automatically appended to the handler list for any job that is submitted to this queue.
+
+`max_parallel_dl` sets the number of simultaneous active downloads that are allowed. The default of five has not been benchmarked in any way, but seems to give acceptable performance.
+
+`requests_session` can be used to provide a `requests` module Session object that will be used to stream remote URLs to disk. This facility was added for use in the module's unit tests to simulate a remote web server, but may be useful in other contexts.
+
+`quiet` will prevent the queue from issuing any log messages at the INFO or higher levels.
+
+### Submitting a download job
+
+You can submit a download job to the queue either by creating the job manually and passing it to the queue's `submit_download_job()` method, or using the `create_download_job()` method, which will do the same thing on your behalf.
+
+To use the former method, follow this example:
+
+```py
+job = DownloadJobRemoteSource(
+ source='http://www.civitai.com/models/13456',
+ destination='/tmp/models/',
+ event_handlers=[my_handler1, my_handler2], # if desired
+)
+queue.submit_download_job(job, start=True)
+```
+
+`submit_download_job()` takes just two arguments: the job to submit, and a flag indicating whether to immediately start the job (defaulting to True). If you choose not to start the job immediately, you can start it later by calling the queue's `start_job()` or `start_all_jobs()` methods, which are described later.
+
+To have the queue create the job for you, follow this example instead:
+
+```py
+job = queue.create_download_job(
+ source='http://www.civitai.com/models/13456',
+ destdir='/tmp/models/',
+ filename='my_model.safetensors',
+ event_handlers=[my_handler1, my_handler2], # if desired
+ start=True,
+ )
+```
+
+The `filename` argument forces the downloader to use the specified name for the file rather than the name provided by the remote source, and is equivalent to manually specifying a destination of `/tmp/models/my_model.safetensors' in the submitted job.
+
+Here is the full list of arguments that can be provided to `create_download_job()`:
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `source` | Union[str, Path, AnyHttpUrl] | | Download remote or local source |
+| `destdir` | Path | | Destination directory for downloaded file |
+| `filename` | Path | None | Filename for downloaded file |
+| `start` | bool | True | Enqueue the job immediately |
+| `priority` | int | 10 | Starting priority for this job |
+| `access_token` | str | None | Authorization token for this resource |
+| `event_handlers` | List[DownloadEventHandler] | [] | Event handlers for this job |
+
+Internally, `create_download_job()` has a little bit of internal logic that looks at the type of the source and selects the right subclass of `DownloadJobBase` to create and enqueue.
+
+**TODO**: move this logic into its own method for overriding in subclasses.
+
+### Job control
+
+Prior to completion, jobs can be controlled with a series of queue method calls. Do not attempt to modify jobs by directly writing to their fields, as this is likely to lead to unexpected results.
+
+Any method that accepts a job argument may raise an `UnknownJobIDException` if the job has not yet been submitted to the queue or was not created by this queue.
+
+#### queue.join()
+
+This method will block until all the active jobs in the queue have reached a terminal state (completed, errored or cancelled).
+
+#### queue.wait_for_job(job, [timeout])
+
+This method will block until the indicated job has reached a terminal state (completed, errored or cancelled). If the optional timeout is provided, the call will block for at most timeout seconds, and raise a TimeoutError otherwise.
+
+#### jobs = queue.list_jobs()
+
+This will return a list of all jobs, including ones that have not yet been enqueued and those that have completed or errored out.
+
+#### job = queue.id_to_job(int)
+
+This method allows you to recover a submitted job using its ID.
+
+#### queue.prune_jobs()
+
+Remove completed and errored jobs from the job list.
+
+#### queue.start_job(job)
+
+If the job was submitted with `start=False`, then it can be started using this method.
+
+#### queue.pause_job(job)
+
+This will temporarily pause the job, if possible. It can later be restarted and pick up where it left off using `queue.start_job()`.
+
+#### queue.cancel_job(job)
+
+This will cancel the job if possible and clean up temporary files and other resources that it might have been using.
+
+#### queue.start_all_jobs(), queue.pause_all_jobs(), queue.cancel_all_jobs()
+
+This will start/pause/cancel all jobs that have been submitted to the queue and have not yet reached a terminal state.
+
+---
+
+## This Meta be Good: Model Metadata Storage
+
+The modules found under `invokeai.backend.model_manager.metadata` provide a straightforward API for fetching model metadatda from online repositories. Currently only HuggingFace is supported. However, the modules are easily extended for additional repos, provided that they have defined APIs for metadata access.
+
+Metadata comprises any descriptive information that is not essential for getting the model to run. For example "author" is metadata, while "type", "base" and "format" are not. The latter fields are part of the model's config, as defined in `invokeai.backend.model_manager.config`.
+
+### Example Usage
+
+```py
+from invokeai.backend.model_manager.metadata import (
+ AnyModelRepoMetadata,
+)
+# to access the initialized sql database
+from invokeai.app.api.dependencies import ApiDependencies
+
+hf = HuggingFaceMetadataFetch()
+
+# fetch the metadata
+model_metadata = hf.from_id("")
+
+assert isinstance(model_metadata, HuggingFaceMetadata)
+```
+
+### Structure of the Metadata objects
+
+There is a short class hierarchy of Metadata objects, all of which descend from the Pydantic `BaseModel`.
+
+#### `ModelMetadataBase`
+
+This is the common base class for metadata:
+
+| Field Name | Type | Description |
+|------------|------|-------------|
+| `name` | str | Repository's name for the model |
+| `author` | str | Model's author |
+| `tags` | Set[str] | Model tags |
+
+Note that the model config record also has a `name` field. It is intended that the config record version be locally customizable, while the metadata version is read-only. However, enforcing this is expected to be part of the business logic.
+
+Descendents of the base add additional fields.
+
+#### `HuggingFaceMetadata`
+
+This descends from `ModelMetadataBase` and adds the following fields:
+
+| Field Name | Type | Description |
+|------------|------|-------------|
+| `type` | Literal["huggingface"] | Used for the discriminated union of metadata classes |
+| `id` | str | HuggingFace repo_id |
+| `tag_dict` | Dict[str, Any] | A dictionary of tag/value pairs provided in addition to `tags` |
+| `last_modified` | datetime | Date of last commit of this model to the repo |
+| `files` | List[Path] | List of the files in the model repo |
+
+#### `AnyModelRepoMetadata`
+
+This is a discriminated Union of `HuggingFaceMetadata`.
+
+### Fetching Metadata from Online Repos
+
+The `HuggingFaceMetadataFetch` class will retrieve metadata from its corresponding repository and return `AnyModelRepoMetadata` objects. Their base class `ModelMetadataFetchBase` is an abstract class that defines two methods: `from_url()` and `from_id()`. The former accepts the type of model URLs that the user will try to cut and paste into the model import form. The latter accepts a string ID in the format recognized by the repository of choice. Both methods return an `AnyModelRepoMetadata`.
+
+The base class also has a class method `from_json()` which will take the JSON representation of a `ModelMetadata` object, validate it, and return the corresponding `AnyModelRepoMetadata` object.
+
+When initializing one of the metadata fetching classes, you may provide a `requests.Session` argument. This allows you to customize the low-level HTTP fetch requests and is used, for instance, in the testing suite to avoid hitting the internet.
+
+The HuggingFace fetcher subclass add additional repo-specific fetching methods:
+
+#### HuggingFaceMetadataFetch
+
+This overrides its base class `from_json()` method to return a `HuggingFaceMetadata` object directly.
+
+### Metadata Storage
+
+The `ModelConfigBase` stores this response in the `source_api_response` field as a JSON blob.
+
+---
+
+## The Lowdown on the ModelLoadService
+
+The `ModelLoadService` is responsible for loading a named model into memory so that it can be used for inference. Despite the fact that it does a lot under the covers, it is very straightforward to use.
+
+An application-wide model loader is created at API initialization time and stored in `ApiDependencies.invoker.services.model_loader`. However, you can create alternative instances if you wish.
+
+### Creating a ModelLoadService object
+
+The class is defined in `invokeai.app.services.model_load`. It is initialized with an InvokeAIAppConfig object, from which it gets configuration information such as the user's desired GPU and precision, and with a previously-created `ModelRecordServiceBase` object, from which it loads the requested model's configuration information.
+
+Here is a typical initialization pattern:
+
+```py
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.model_load import ModelLoadService, ModelLoaderRegistry
+
+config = InvokeAIAppConfig.get_config()
+
+ram_cache = ModelCache(
+ max_cache_size=config.ram_cache_size, max_vram_cache_size=config.vram_cache_size, logger=logger
+)
+
+convert_cache = ModelConvertCache(
+ cache_path=config.models_convert_cache_path, max_size=config.convert_cache_size
+)
+
+loader = ModelLoadService(
+ app_config=config,
+ ram_cache=ram_cache,
+ convert_cache=convert_cache,
+ registry=ModelLoaderRegistry
+)
+```
+
+### load_model(model_config, [submodel_type], [context]) -> LoadedModel
+
+The `load_model()` method takes an `AnyModelConfig` returned by `ModelRecordService.get_model()` and returns the corresponding loaded model. It loads the model into memory, gets the model ready for use, and returns a `LoadedModel` object.
+
+The optional second argument, `subtype` is a `SubModelType` string enum, such as "vae". It is mandatory when used with a main model, and is used to select which part of the main model to load.
+
+The optional third argument, `context` can be provided by an invocation to trigger model load event reporting. See below for details.
+
+The returned `LoadedModel` object contains a copy of the configuration record returned by the model record`get_model()` method, as well as the in-memory loaded model:
+
+| Attribute Name | Type | Description |
+|----------------|------|-------------|
+| `config` | AnyModelConfig | A copy of the model's configuration record for retrieving base type, etc. |
+| `model` | AnyModel | The instantiated model (details below) |
+
+### get_model_by_key(key, [submodel]) -> LoadedModel
+
+The `get_model_by_key()` method will retrieve the model using its unique database key. For example:
+
+```py
+loaded_model = loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+```
+
+`get_model_by_key()` may raise any of the following exceptions:
+
+* `UnknownModelException` -- key not in database
+* `ModelNotFoundException` -- key in database but model not found at path
+* `NotImplementedException` -- the loader doesn't know how to load this type of model
+
+### Using the Loaded Model in Inference
+
+`LoadedModel` acts as a context manager. The context loads the model into the execution device (e.g. VRAM on CUDA systems), locks the model in the execution device for the duration of the context, and returns the model. Use it like this:
+
+```py
+loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+with loaded_model as vae:
+ image = vae.decode(latents)[0]
+```
+
+The object returned by the LoadedModel context manager is an `AnyModel`, which is a Union of `ModelMixin`, `torch.nn.Module`, `IAIOnnxRuntimeModel`, `IPAdapter`, `IPAdapterPlus`, and `EmbeddingModelRaw`. `ModelMixin` is the base class of all diffusers models, `EmbeddingModelRaw` is used for LoRA and TextualInversion models. The others are obvious.
+
+In addition, you may call `LoadedModel.model_on_device()`, a context manager that returns a tuple of the model's state dict in CPU and the model itself in VRAM. It is used to optimize the LoRA patching and unpatching process:
+
+```py
+loaded_model_= loader.get_model_by_key('f13dd932c0c35c22dcb8d6cda4203764', SubModelType('vae'))
+with loaded_model.model_on_device() as (state_dict, vae):
+ image = vae.decode(latents)[0]
+```
+
+Since not all models have state dicts, the `state_dict` return value can be None.
+
+### Emitting model loading events
+
+When the `context` argument is passed to `load_model_*()`, it will retrieve the invocation event bus from the passed `InvocationContext` object to emit events on the invocation bus. The two events are "model_load_started" and "model_load_completed". Both carry the following payload:
+
+```py
+payload=dict(
+ queue_id=queue_id,
+ queue_item_id=queue_item_id,
+ queue_batch_id=queue_batch_id,
+ graph_execution_state_id=graph_execution_state_id,
+ model_key=model_key,
+ submodel_type=submodel,
+ hash=model_info.hash,
+ location=str(model_info.location),
+ precision=str(model_info.precision),
+)
+```
+
+### Adding Model Loaders
+
+Model loaders are small classes that inherit from the `ModelLoader` base class. They typically implement one method `_load_model()` whose signature is:
+
+```py
+def _load_model(
+ self,
+ model_path: Path,
+ model_variant: Optional[ModelRepoVariant] = None,
+ submodel_type: Optional[SubModelType] = None,
+) -> AnyModel:
+```
+
+`_load_model()` will be passed the path to the model on disk, an optional repository variant (used by the diffusers loaders to select, e.g. the `fp16` variant, and an optional submodel_type for main and onnx models.
+
+To install a new loader, place it in `invokeai/backend/model_manager/load/model_loaders`. Inherit from `ModelLoader` and use the `@ModelLoaderRegistry.register()` decorator to indicate what type of models the loader can handle.
+
+Here is a complete example from `generic_diffusers.py`, which is able to load several different diffusers types:
+
+```py
+from pathlib import Path
+from typing import Optional
+
+from invokeai.backend.model_manager import (
+ AnyModel,
+ BaseModelType,
+ ModelFormat,
+ ModelRepoVariant,
+ ModelType,
+ SubModelType,
+)
+from .. import ModelLoader, ModelLoaderRegistry
+
+
+@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.CLIPVision, format=ModelFormat.Diffusers)
+@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.T2IAdapter, format=ModelFormat.Diffusers)
+class GenericDiffusersLoader(ModelLoader):
+ """Class to load simple diffusers models."""
+
+ def _load_model(
+ self,
+ model_path: Path,
+ model_variant: Optional[ModelRepoVariant] = None,
+ submodel_type: Optional[SubModelType] = None,
+ ) -> AnyModel:
+ model_class = self._get_hf_load_class(model_path)
+ if submodel_type is not None:
+ raise Exception(f"There are no submodels in models of type {model_class}")
+ variant = model_variant.value if model_variant else None
+ result: AnyModel = model_class.from_pretrained(model_path, torch_dtype=self._torch_dtype, variant=variant) # type: ignore
+ return result
+```
+
+:::note
+ A loader can register itself to handle several different
+ model types. An exception will be raised if more than one loader tries
+ to register the same model type.
+:::
+
+#### Conversion
+
+Some models require conversion to diffusers format before they can be loaded. These loaders should override two additional methods:
+
+```py
+_needs_conversion(self, config: AnyModelConfig, model_path: Path, dest_path: Path) -> bool
+_convert_model(self, config: AnyModelConfig, model_path: Path, output_path: Path) -> Path:
+```
+
+The first method accepts the model configuration, the path to where the unmodified model is currently installed, and a proposed destination for the converted model. This method returns True if the model needs to be converted. It typically does this by comparing the last modification time of the original model file to the modification time of the converted model. In some cases you will also want to check the modification date of the configuration record, in the event that the user has changed something like the scheduler prediction type that will require the model to be re-converted. See `controlnet.py` for an example of this logic.
+
+The second method accepts the model configuration, the path to the original model on disk, and the desired output path for the converted model. It does whatever it needs to do to get the model into diffusers format, and returns the Path of the resulting model. (The path should ordinarily be the same as `output_path`.)
+
+## The ModelManagerService object
+
+For convenience, the API provides a `ModelManagerService` object which gives a single point of access to the major model manager services. This object is created at initialization time and can be found in the global `ApiDependencies.invoker.services.model_manager` object, or in `context.services.model_manager` from within an invocation.
+
+In the examples below, we have retrieved the manager using:
+
+```py
+mm = ApiDependencies.invoker.services.model_manager
+```
+
+The following properties and methods will be available:
+
+### mm.store
+
+This retrieves the `ModelRecordService` associated with the manager. Example:
+
+```py
+configs = mm.store.get_model_by_attr(name='stable-diffusion-v1-5')
+```
+
+### mm.install
+
+This retrieves the `ModelInstallService` associated with the manager. Example:
+
+```py
+job = mm.install.heuristic_import(`https://civitai.com/models/58390/detail-tweaker-lora-lora`)
+```
+
+### mm.load
+
+This retrieves the `ModelLoaderService` associated with the manager. Example:
+
+```py
+configs = mm.store.get_model_by_attr(name='stable-diffusion-v1-5')
+assert len(configs) > 0
+
+loaded_model = mm.load.load_model(configs[0])
+```
+
+The model manager also offers a few convenience shortcuts for loading models:
+
+### mm.load_model_by_config(model_config, [submodel], [context]) -> LoadedModel
+
+Same as `mm.load.load_model()`.
+
+### mm.load_model_by_attr(model_name, base_model, model_type, [submodel], [context]) -> LoadedModel
+
+This accepts the combination of the model's name, type and base, which it passes to the model record config store for retrieval. If a unique model config is found, this method returns a `LoadedModel`. It can raise the following exceptions:
+
+- `UnknownModelException` -- model with these attributes not known
+- `NotImplementedException` -- the loader doesn't know how to load this type of model
+- `ValueError` -- more than one model matches this combination of base/type/name
+
+### mm.load_model_by_key(key, [submodel], [context]) -> LoadedModel
+
+This method takes a model key, looks it up using the `ModelRecordServiceBase` object in `mm.store`, and passes the returned model configuration to `load_model_by_config()`. It may raise a `NotImplementedException`.
+
+## Invocation Context Model Manager API
+
+Within invocations, the following methods are available from the `InvocationContext` object:
+
+### context.download_and_cache_model(source) -> Path
+
+This method accepts a `source` of a remote model, downloads and caches it locally, and then returns a Path to the local model. The source can be a direct download URL or a HuggingFace repo_id.
+
+In the case of HuggingFace repo_id, the following variants are recognized:
+
+* stabilityai/stable-diffusion-v4 -- default model
+* stabilityai/stable-diffusion-v4:fp16 -- fp16 variant
+* stabilityai/stable-diffusion-v4:fp16:vae -- the fp16 vae subfolder
+* stabilityai/stable-diffusion-v4:onnx:vae -- the onnx variant vae subfolder
+
+You can also point at an arbitrary individual file within a repo_id directory using this syntax:
+
+* stabilityai/stable-diffusion-v4::/checkpoints/sd4.safetensors
+
+### context.load_local_model(model_path, [loader]) -> LoadedModel
+
+This method loads a local model from the indicated path, returning a `LoadedModel`. The optional loader is a Callable that accepts a Path to the object, and returns a `AnyModel` object. If no loader is provided, then the method will use `torch.load()` for a .ckpt or .bin checkpoint file, `safetensors.torch.load_file()` for a safetensors checkpoint file, or `cls.from_pretrained()` for a directory that looks like a diffusers directory.
+
+### context.load_remote_model(source, [loader]) -> LoadedModel
+
+This method accepts a `source` of a remote model, downloads and caches it locally, loads it, and returns a `LoadedModel`. The source can be a direct download URL or a HuggingFace repo_id.
+
+In the case of HuggingFace repo_id, the following variants are recognized:
+
+* stabilityai/stable-diffusion-v4 -- default model
+* stabilityai/stable-diffusion-v4:fp16 -- fp16 variant
+* stabilityai/stable-diffusion-v4:fp16:vae -- the fp16 vae subfolder
+* stabilityai/stable-diffusion-v4:onnx:vae -- the onnx variant vae subfolder
+
+You can also point at an arbitrary individual file within a repo_id directory using this syntax:
+
+* stabilityai/stable-diffusion-v4::/checkpoints/sd4.safetensors
diff --git a/docs/src/content/docs/development/Architecture/overview.mdx b/docs/src/content/docs/development/Architecture/overview.mdx
new file mode 100644
index 00000000000..8cbe18efad5
--- /dev/null
+++ b/docs/src/content/docs/development/Architecture/overview.mdx
@@ -0,0 +1,104 @@
+---
+title: Architecture Overview
+sidebar:
+ order: 1
+ label: Overview
+
+lastUpdated: 2026-02-18
+---
+
+import Mermaid from '@components/Mermaid.astro'
+
+
+```mermaid
+flowchart TB
+
+ subgraph apps[Applications]
+ webui[WebUI]
+ cli[CLI]
+
+ subgraph webapi[Web API]
+ api[HTTP API]
+ sio[Socket.IO]
+ end
+
+ end
+
+ subgraph invoke[Invoke]
+ direction LR
+ invoker
+ services
+ sessions
+ invocations
+ end
+
+ subgraph core[AI Core]
+ Generate
+ end
+
+ webui --> webapi
+ webapi --> invoke
+ cli --> invoke
+
+ invoker --> services & sessions
+ invocations --> services
+ sessions --> invocations
+
+ services --> core
+
+ %% Styles
+ classDef sg fill:#5028C8,font-weight:bold,stroke-width:2,color:#fff,stroke:#14141A
+ classDef default stroke-width:2px,stroke:#F6B314,color:#fff,fill:#14141A
+
+ class apps,webapi,invoke,core sg
+
+```
+
+
+## Applications
+
+Applications are built on top of the invoke framework. They should construct `invoker` and then interact through it. They should avoid interacting directly with core code in order to support a variety of configurations.
+
+### Web UI
+
+The Web UI is built on top of an HTTP API built with [FastAPI](https://fastapi.tiangolo.com/) and [Socket.IO](https://socket.io/). The frontend code is found in `/invokeai/frontend` and the backend code is found in `/invokeai/app/api_app.py` and `/invokeai/app/api/`. The code is further organized as such:
+
+| Component | Description |
+| --- | --- |
+| api_app.py | Sets up the API app, annotates the OpenAPI spec with additional data, and runs the API |
+| dependencies | Creates all invoker services and the invoker, and provides them to the API |
+| events | An eventing system that could in the future be adapted to support horizontal scale-out |
+| sockets | The Socket.IO interface - handles listening to and emitting session events (events are defined in the events service module) |
+| routers | API definitions for different areas of API functionality |
+
+### CLI
+
+The CLI is built automatically from invocation metadata, and also supports invocation piping and auto-linking. Code is available in `/invokeai/frontend/cli`.
+
+## Invoke
+
+The Invoke framework provides the interface to the underlying AI systems and is built with flexibility and extensibility in mind. There are four major concepts: invoker, sessions, invocations, and services.
+
+### Invoker
+
+The invoker (`/invokeai/app/services/invoker.py`) is the primary interface through which applications interact with the framework. Its primary purpose is to create, manage, and invoke sessions. It also maintains two sets of services:
+- **invocation services**, which are used by invocations to interact with core functionality.
+- **invoker services**, which are used by the invoker to manage sessions and manage the invocation queue.
+
+### Sessions
+
+Invocations and links between them form a graph, which is maintained in a session. Sessions can be queued for invocation, which will execute their graph (either the next ready invocation, or all invocations). Sessions also maintain execution history for the graph (including storage of any outputs). An invocation may be added to a session at any time, and there is capability to add and entire graph at once, as well as to automatically link new invocations to previous invocations. Invocations can not be deleted or modified once added.
+
+The session graph does not support looping. This is left as an application problem to prevent additional complexity in the graph.
+
+### 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
+
+Services provide invocations access AI Core functionality and other necessary functionality (e.g. image storage). These are available in `/invokeai/app/services`. As a general rule, new services should provide an interface as an abstract base class, and may provide a lightweight local implementation by default in their module. The goal for all services should be to enable the usage of different implementations (e.g. using cloud storage for image storage), but should not load any module dependencies unless that implementation has been used (i.e. don't import anything that won't be used, especially if it's expensive to import).
+
+## AI Core
+
+The AI Core is represented by the rest of the code base (i.e. the code outside of `/invokeai/app/`).
diff --git a/docs/src/content/docs/development/Documentation/index.mdx b/docs/src/content/docs/development/Documentation/index.mdx
new file mode 100644
index 00000000000..ae8b5248c1d
--- /dev/null
+++ b/docs/src/content/docs/development/Documentation/index.mdx
@@ -0,0 +1,314 @@
+---
+title: Documentation
+lastUpdated: 2026-05-14
+---
+
+import { Steps, Tabs, TabItem, FileTree } from '@astrojs/starlight/components'
+
+The Invoke AI website, including its documentation are all contained within the `docs` directory.
+
+## Prerequisites
+
+The documentation is built using [Astro Starlight](https://starlight.astro.build/). It's suggested you familiarize yourself with the following technologies before getting started:
+
+
+ 1. [Markdown](https://www.markdownguide.org/) - a lightweight markup language for creating formatted text.
+ 2. [MDX](https://mdxjs.com/) - a superset of Markdown that allows you to use React components in your content.
+ 3. [Astro](https://astro.build/) - a modern static site builder that supports MDX and other front-end technologies.
+ 4. [Starlight](https://starlight.astro.build/) - a theme for Astro that provides a clean and modern documentation experience.
+ 5. [Vite](https://vitejs.dev/) - a fast development server and build tool for modern web projects.
+
+
+Markdown powers the content of every page on the website (including the homepage), with additional help from [MDX](https://mdxjs.com/) to make the pages more interactive with imported React components.
+
+## Navigating the Documentation
+
+The documentation is organized into a file tree structure. It should be very familiar to anyone who has built modern web applications.
+
+
+ - docs/
+ - dist/ production build output
+ - public/ non-optimized, public assets
+ - src/ main source code
+ - assets/ optimized assets
+ - config/ astro/starlight configs
+ - content/ markdown pages and content
+ - docs/ documentation content
+ - i18n/ internationalized content
+ - generated/ generated json files for dynamic content
+ - layouts/ components used to wrap pages
+ - lib/ utility functions and shared code
+ - components/ reusable, custom components
+ - pages/ non-documentation pages
+ - styles/ global styles and themes
+
+
+## Development
+
+If you've ever worked within a react, astro or similar node-based library or framework, you should feel familiar with most of the setup here.
+
+If you're adding a feature, new behavior or etc. that changes how users expect Invoke to work, we expect you to deliver your PR with associated docs to support it. To get started, follow the steps below.
+
+### Dev Environment
+
+There are 2 main ways to get your development environment set up for documentation:
+
+
+
+ Invoke's makefile makes it easy to set up your development environment for documentation in only a couple of commands. You can run these from the root of the repository.
+
+
+ 1. First, install the required dependencies.
+
+ ```sh
+ make docs-install
+ ```
+
+ 2. Next, run the development server.
+
+ ```sh
+ make docs-dev
+ ```
+
+ 3. Open your browser and navigate to `http://localhost:4321` to view the documentation.
+
+
+
+
+ If you prefer good ol' fashioned `cd` and `pnpm` commands, you can set up your development environment manually.
+
+
+ 1. First, cd into the docs directory.
+
+ ```sh
+ cd docs
+ ```
+
+ 2. Next, install the required dependencies.
+
+ ```sh
+ pnpm install
+ ```
+
+ 3. Run the development server.
+
+ ```sh
+ pnpm dev
+ ```
+
+ 4. Open your browser and navigate to `http://localhost:4321` to view the documentation.
+
+
+
+
+If there's another local server running on port `4321` prior to running this, then use the port specified in the output.
+
+### Adding Pages
+
+Located within the `src/content/docs/` directory, this is where the documentation pages are stored and organized by category. These categories are file-based and are mirrored to the sidebar navigation.
+
+:::caution
+Do not place your docs content contributions outside of the `content` directory, it will not be seen.
+:::
+
+If you wish to add a new sub category to document a feature or a behavior, simply create a new directory within the relevant top-level category directory.
+
+For example, if we wanted to document a new feature called "Instant Bananas", we would create a new directory within `src/content/docs/features/` like so:
+
+`src/content/docs/`
+
+ - concepts/
+ - configuration/
+ - contributing/
+ - development/
+ - features/
+ - **instant-bananas/**
+ - **index.md** Write your documentation here
+ - **requirements.mdx** You can add more pages in this directory
+ - start-here/
+ - troubleshooting/
+ - workflows/
+
+
+The way you organize your added pages dictates how the URL structure is generated for your documentation pages. In this example, the url for the `index.md` page would be `https://invoke.ai/features/instant-bananas/`, and the url for the `requirements.mdx` page would be `https://invoke.ai/features/instant-bananas/requirements/`.
+
+If you wish to add a top-level category, then one additional step is required for the category to appear in the sidebar.
+
+Within the `src/config/sidebar.ts` file, you'll need to add a new sidebar category object to the array, since the fine-grained control over top-level categories needs to be a bit more explicit.
+
+```diff lang="js"
+const sidebar = [
+ // ...
+ {
+ label: 'Concepts',
+ items: [
+ {
+ autogenerate: { directory: 'concepts' },
+ },
+ ],
+ },
++ {
++ label: 'A New Category',
++ items: [
++ {
++ autogenerate: { directory: 'new-category' },
++ },
++ ],
++ },
+ {
+ label: 'Features',
+ items: [
+ {
+ autogenerate: { directory: 'features' },
+ },
+ ],
+ },
+ // ...
+]
+```
+
+### Page Metadata
+
+Before your page becomes available, you will need to add frontmatter to define the page's metadata such as its title, description, last update date, sidebar position, and etc.
+
+Learn more about what frontmatter is and how to use it in your pages in the [Starlight Documentation](https://starlight.astro.build/reference/frontmatter/).
+
+Once you have some basic frontmatter defined, you should be able to see it reflected in the sidebar and the page title.
+
+### Adding Images
+
+We encourage adding imagery to your docs for creating a more engaging and visual experience for viewers. To add images, we prefer you to utilize an `assets` directory within the concerning category.
+
+
+ - features/
+ - instant-bananas/
+ - **assets/**
+ - **demonstration.webp**
+ - **foobar.avif**
+ - index.mdx
+
+
+The Astro image optimizer/renderer is quite flexible with image formats and sizes, but we'd prefer stored images to be at reasonable sizes (not 4k), and using optimized formats (webp, avif, jpeg).
+
+To render the image, you'd just use a relative path in your markdown.
+
+```md title="index.mdx"
+
+```
+
+### Adding Translations
+
+Currently, the documentation is only available in English. If you wish to add translations for other languages, we've already laid the ground work for you to do so.
+
+Firstly, add a new folder within the `src/content/i18n` directory, and create your translated version of the markdown file into the same path as the original.
+
+For example:
+
+
+ - src/
+ - content/
+ - docs/
+ - start-here/
+ - installation.mdx
+ - i18n/
+ - zh-CN Country code here
+ - start-here/
+ - installation.mdx
+
+
+We recommend simply copy/pasting the file and rewriting the text from there.
+
+Learn more about the intricacies of translating Astro Starlight docs [here](https://starlight.astro.build/guides/i18n).
+
+## Running a Build
+
+Modifications to the docs may run fine on your machine, but as we've learned the hard way, GitHub pages flips that expectation completely. So, we've added some ways to ensure things work as expected before deploying.
+
+Just like with the dev environment, you can build the docs one of two ways:
+
+
+
+ Invoke's makefile makes it easy to build the documentation in only a single command. You can run it from the root of the repository.
+
+
+ 1. First, run the build command.
+
+ ```sh
+ make docs-build
+ ```
+
+ 2. Finally, preview the output.
+
+ ```sh
+ make docs-preview
+ ```
+
+
+ And that's it.
+
+ :::tip[Deploy Target]
+ The make command here sets the `DEPLOY_TARGET` environment variable to `custom`, so that the final output matches what you'd expect from the final deployment to https://invoke.ai.
+
+ If you'd rather set a different deploy target, use the manual method.
+ :::
+
+
+
+ If you prefer good ol' fashioned `cd` and `pnpm` commands, or to have granular control over environment variables, you can run the following:
+
+
+ 1. First, cd into the `docs` directory.
+
+ ```sh
+ cd docs
+ ```
+
+ 2. Next run the build command.
+
+ ```sh
+ pnpm run build
+ ```
+
+ 3. Finally, preview the build.
+
+ ```sh
+ pnpm run preview
+ ```
+
+ The preview url will be available on the same port as the dev server.
+
+
+
+
+
+## Generated Files
+
+The Invoke API is always evolving, and quite large. Documenting all this by hand would be wildly impractical, so there's a script we've set up to pull all that data and generate relevant json files into `generated` directory.
+
+These files are used for the [YAML Config](/configuration/invokeai-yaml) and [API Development](/development/guides/api-development) pages. If you're adding a feature that changes the yaml config, or the api then make sure to run `pnpm run generate-docs-data` to ensure tests pass, and that the docs are accurate in accordance to your updates.
+
+## Testing
+
+The docs contain tests for the following:
+
+| Test | Description | Runs on... |
+| -- | -- | -- |
+| Link Checker | Checks for invalid, malformed or misdirected internal link URLs | Dev Server, Build, Deploy |
+| Verify Deployment Output | Check to ensure the asset and page paths have the expected base paths dependent on deploy targets | Build, Deploy |
+| Check Docs Data | Checks to ensure the generated files are accurate | Deploy |
+
+## GitHub Actions
+
+Once you've submitted your updated docs, either via pull request or a main push to your own fork, the `deploy-docs` action will run.
+
+The `deploy-docs` action will install the necessary dependencies, run a build, test and serve the docs on github pages. Any failing deployments will require fixing before deploying.
+
+## Troubleshooting
+
+#### All the styles are missing and the links are wrong, what happened?
+
+This commonly happens when the base path and the deploy target are mismatched, check those first and then run your build again.
+
+#### Redirects aren't working on the production deployment, but they work locally, why?
+
+Because GitHub Pages' SSR environment is lackluster, and thus doesn't handle backend redirects. We included a redirects configuration just in case GitHub ever grows a conscience, or if the docs ever get deployed someplace else.
diff --git a/docs/src/content/docs/development/Front End/canvas-projects.mdx b/docs/src/content/docs/development/Front End/canvas-projects.mdx
new file mode 100644
index 00000000000..b36c2ce9720
--- /dev/null
+++ b/docs/src/content/docs/development/Front End/canvas-projects.mdx
@@ -0,0 +1,54 @@
+---
+title: Canvas Projects
+---
+
+Canvas projects serialize the current canvas into a portable `.invk` archive. The feature lives in `invokeai/frontend/web/src/features/controlLayers/` and is exposed in the canvas toolbar archive menu and the canvas context menu under **Project**.
+
+## File format
+
+`.invk` files are ZIP archives. The current manifest version is `1`.
+
+Each archive contains:
+
+| Target | Description |
+| - | - |
+| `manifest.json` | project metadata, including the archive version, app version, creation timestamp, and project name. |
+| `canvas_state.json` | raster layers, control layers, inpaint masks, regional guidance, bounding box state, and selected/bookmarked entity identifiers. |
+| `params.json` | generation parameter state. |
+| `ref_images.json` | global reference image state. |
+| `loras.json` | active LoRA state. |
+| `images/` | image blobs referenced by the canvas or reference image state. |
+
+The save path builds this archive in `useCanvasProjectSave.ts`. It collects all referenced `image_name` values, fetches each image from the server, writes successfully fetched files under `images/`, and downloads the ZIP with the `.invk` extension. Failed image fetches are logged and skipped rather than aborting the save.
+
+## Image collection
+
+Image references are collected by `collectImageNames()` in `canvasProjectFile.ts`.
+
+The collector checks:
+
+- Image objects in raster layers.
+- Image objects in control layers.
+- Image objects in inpaint masks.
+- Image objects and IP Adapter / Flux Redux reference images in regional guidance.
+- Global reference images, including cropped source images.
+
+Image fetches are concurrency-limited with `processWithConcurrencyLimit()` so large projects do not flood the browser or backend with simultaneous requests.
+
+## Loading and remapping
+
+The load path is implemented in `useCanvasProjectLoad.ts`.
+
+Loading validates `manifest.json`, requires `canvas_state.json`, and reads optional `params.json`, `ref_images.json`, and `loras.json` files. Before restoring state, it checks whether each referenced image already exists on the server with `checkExistingImages()`.
+
+Only missing images are uploaded from the archive. If a referenced missing image is not present in `images/`, the loader logs a warning and leaves that reference unchanged. If an upload returns a different `image_name`, the loader records an old-to-new mapping and remaps image references before dispatching restored canvas and reference image state.
+
+LoRAs are cleared before project LoRAs are recalled. This prevents LoRAs from the previous canvas session from leaking into the loaded project.
+
+Image existence checks and uploads are also concurrency-limited.
+
+## Compatibility notes
+
+The archive stores references to models, LoRAs, and other generation resources, not the model files themselves. Loading a project on another install can restore the canvas images and state, but missing model resources still need to be installed or replaced by the user.
+
+Future format changes should increment `CANVAS_PROJECT_VERSION` and keep validation in `parseManifest()` explicit so unsupported project files fail early.
diff --git a/docs/src/content/docs/development/Front End/index.md b/docs/src/content/docs/development/Front End/index.md
new file mode 100644
index 00000000000..4e12e59efe2
--- /dev/null
+++ b/docs/src/content/docs/development/Front End/index.md
@@ -0,0 +1,131 @@
+---
+title: Frontend Development
+lastUpdated: 2026-02-18
+---
+
+Invoke's UI is made possible by many contributors and open-source libraries. Thank you!
+
+## Dev environment
+
+Follow the [dev environment](/development/setup/dev-environment/) guide to get set up. Run the UI using `pnpm dev`.
+
+## Package scripts
+
+- `dev`: run the frontend in dev mode, enabling hot reloading
+- `build`: run all checks (dpdm, eslint, prettier, tsc, knip) and then build the frontend
+- `lint:dpdm`: check circular dependencies
+- `lint:eslint`: check code quality
+- `lint:prettier`: check code formatting
+- `lint:tsc`: check type issues
+- `lint:knip`: check for unused exports or objects
+- `lint`: run all checks concurrently
+- `fix`: run `eslint` and `prettier`, fixing fixable issues
+- `test:ui`: run `vitest` with the fancy web UI
+
+## Type generation
+
+We use [openapi-typescript] to generate types from the app's OpenAPI schema. The generated types are committed to the repo in [schema.ts].
+
+If you make backend changes, it's important to regenerate the frontend types:
+
+```sh
+cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen
+```
+
+On macOS and Linux, you can run `make frontend-typegen` as a shortcut for the above snippet.
+
+## Localization
+
+We use [i18next] for localization, but translation to languages other than English happens on our [Weblate] project.
+
+Only the English source strings (i.e. `en.json`) should be changed on this repo.
+
+## VSCode
+
+### Example debugger config
+
+```jsonc
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "chrome",
+ "request": "launch",
+ "name": "Invoke UI",
+ "url": "http://localhost:5173",
+ "webRoot": "${workspaceFolder}/invokeai/frontend/web"
+ }
+ ]
+}
+```
+
+### Remote dev
+
+We've noticed an intermittent timeout issue with the VSCode remote dev port forwarding.
+
+We suggest disabling the editor's port forwarding feature and doing it manually via SSH:
+
+```sh
+ssh -L 9090:localhost:9090 -L 5173:localhost:5173 user@host
+```
+
+## Contributing Guidelines
+
+Thanks for your interest in contributing to the Invoke Web UI!
+
+Please follow these guidelines when contributing.
+
+## Check in before investing your time
+
+Please check in before you invest your time on anything besides a trivial fix, in case it conflicts with ongoing work or isn't aligned with the vision for the app.
+
+If a feature request or issue doesn't already exist for the thing you want to work on, please create one.
+
+Ping `@psychedelicious` on [discord] in the `#frontend-dev` channel or in the feature request / issue you want to work on - we're happy to chat.
+
+## Code conventions
+
+- This is a fairly complex app with a deep component tree. Please use memoization (`useCallback`, `useMemo`, `memo`) with enthusiasm.
+- If you need to add some global, ephemeral state, please use [nanostores] if possible.
+- Be careful with your redux selectors. If they need to be parameterized, consider creating them inside a `useMemo`.
+- Feel free to use `lodash` (via `lodash-es`) to make the intent of your code clear.
+- Please add comments describing the "why", not the "how" (unless it is really arcane).
+
+## Commit format
+
+Please use the [conventional commits] spec for the web UI, with a scope of "ui":
+
+- `chore(ui): bump deps`
+- `chore(ui): lint`
+- `feat(ui): add some cool new feature`
+- `fix(ui): fix some bug`
+
+## Tests
+
+We don't do any UI testing at this time, but consider adding tests for sensitive logic.
+
+We use `vitest`, and tests should be next to the file they are testing. If the logic is in `something.ts`, the tests should be in `something.test.ts`.
+
+In some situations, we may want to test types. For example, if you use `zod` to create a schema that should match a generated type, it's best to add a test to confirm that the types match. Use `tsafe`'s assert for this.
+
+## Submitting a PR
+
+- Ensure your branch is tidy. Use an interactive rebase to clean up the commit history and reword the commit messages if they are not descriptive.
+- Run `pnpm lint`. Some issues are auto-fixable with `pnpm fix`.
+- Fill out the PR form when creating the PR.
+ - It doesn't need to be super detailed, but a screenshot or video is nice if you changed something visually.
+ - If a section isn't relevant, delete it.
+
+## Other docs
+
+- [Workflows - Design and Implementation]
+- [State Management]
+
+[discord]: https://discord.gg/ZmtBAhwWhy
+[i18next]: https://github.com/i18next/react-i18next
+[Weblate]: https://hosted.weblate.org/engage/invokeai/
+[openapi-typescript]: https://github.com/openapi-ts/openapi-typescript
+[schema.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/services/api/schema.ts
+[conventional commits]: https://www.conventionalcommits.org/en/v1.0.0/
+[Workflows - Design and Implementation]: ./workflows/
+[State Management]: ./state-management/
diff --git a/docs/src/content/docs/development/Front End/state-management.mdx b/docs/src/content/docs/development/Front End/state-management.mdx
new file mode 100644
index 00000000000..96fede7ba7f
--- /dev/null
+++ b/docs/src/content/docs/development/Front End/state-management.mdx
@@ -0,0 +1,41 @@
+---
+title: State Management
+lastUpdated: 2026-02-18
+---
+
+The app makes heavy use of Redux Toolkit, its Query library, and `nanostores`.
+
+## Redux
+
+We use RTK extensively - slices, entity adapters, queries, reselect, the whole 9 yards. Their [docs](https://redux-toolkit.js.org/) are excellent.
+
+## `nanostores`
+
+[nanostores] is a tiny state management library. It provides both imperative and declarative APIs.
+
+### Example
+
+```ts
+export const $myStringOption = atom(null);
+
+// Outside a component, or within a callback for performance-critical logic
+$myStringOption.get();
+$myStringOption.set('new value');
+
+// Inside a component
+const myStringOption = useStore($myStringOption);
+```
+
+### Where to put nanostores
+
+- For global application state, export your stores from `invokeai/frontend/web/src/app/store/nanostores/`.
+- For feature state, create a file for the stores next to the redux slice definition (e.g. `invokeai/frontend/web/src/features/myFeature/myFeatureNanostores.ts`).
+- For hooks with global state, export the store from the same file the hook is in, or put it next to the hook.
+
+### When to use nanostores
+
+- For non-serializable data that needs to be available throughout the app, use `nanostores` instead of a global.
+- For ephemeral global state (i.e. state that does not need to be persisted), use `nanostores` instead of redux.
+- For performance-critical code and in callbacks, redux selectors can be problematic due to the declarative reactivity system. Consider refactoring to use `nanostores` if there's a **measurable** performance issue.
+
+[nanostores]: https://github.com/nanostores/nanostores/
diff --git a/docs/src/content/docs/development/Front End/text-tool.mdx b/docs/src/content/docs/development/Front End/text-tool.mdx
new file mode 100644
index 00000000000..5b19bbeef9f
--- /dev/null
+++ b/docs/src/content/docs/development/Front End/text-tool.mdx
@@ -0,0 +1,37 @@
+---
+title: "Canvas Text Tool"
+---
+
+## Overview
+
+The canvas text workflow is split between a Konva module that owns tool state and a React overlay that handles text entry.
+
+- `invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts`
+ - Owns the tool, cursor preview, and text session state (including the cursor "T" marker).
+ - Manages dynamic cursor contrast, starts sessions on pointer down, and commits sessions by rasterizing the active text block into a new raster layer.
+- `invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx`
+ - Renders the on-canvas editor as a `contentEditable` overlay positioned in canvas space.
+ - Syncs keyboard input, suppresses app hotkeys, and forwards commits/cancels to the Konva module.
+- `invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx`
+ - Provides the font dropdown, size slider/input, formatting toggles, and alignment buttons that appear when the Text tool is active.
+
+## Rasterization pipeline
+
+`renderTextToCanvas()` (`invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts`) converts the editor contents into a transparent canvas. The Text tool module configures the renderer with the active font stack, weight, styling flags, alignment, and the active canvas color. The resulting canvas is encoded to a PNG data URL and stored in a new raster layer (`image` object) with a transparent background.
+
+Layer placement preserves the original click location:
+
+- The session stores the anchor coordinate (where the user clicked) and current alignment.
+- `calculateLayerPosition()` calculates the top-left position for the raster layer after applying the configured padding and alignment offsets.
+- New layers are inserted directly above the currently-selected raster layer (when present) and selected automatically.
+
+## Font stacks
+
+Font definitions live in `invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts` as ten deterministic stacks (sans, serif, mono, rounded, script, humanist, slab serif, display, narrow, UI serif). Each stack lists system-safe fallbacks so the editor can choose the first available font per platform.
+
+To add or adjust fonts:
+
+1. Update `TEXT_FONT_STACKS` with the new `id`, `label`, and CSS `font-family` stack.
+2. If you add a new stack, extend the `TEXT_FONT_IDS` tuple and update the `canvasTextSlice` schema default (`TEXT_DEFAULT_FONT_ID`).
+3. Provide translation strings for any new labels in `public/locales/*`.
+4. The editor and renderer will automatically pick up the new stack via `getFontStackById()`.
diff --git a/docs/src/content/docs/development/Front End/workflows.mdx b/docs/src/content/docs/development/Front End/workflows.mdx
new file mode 100644
index 00000000000..d083bb8df1e
--- /dev/null
+++ b/docs/src/content/docs/development/Front End/workflows.mdx
@@ -0,0 +1,315 @@
+---
+title: Workflows
+lastUpdated: 2026-02-18
+---
+
+This document describes, at a high level, the design and implementation of workflows in the InvokeAI frontend. There are a substantial number of implementation details not included, but which are hopefully clear from the code.
+
+InvokeAI's backend uses graphs, composed of **nodes** and **edges**, to process data and generate images.
+
+Nodes have any number of **input fields** and **output fields**. Edges connect nodes together via their inputs and outputs. Fields have data types which dictate how they may be connected.
+
+During execution, a nodes' outputs may be passed along to any number of other nodes' inputs.
+
+Workflows are an enriched abstraction over a graph.
+
+## Design
+
+InvokeAI provide two ways to build graphs in the frontend: the [Linear UI](#linear-ui) and [Workflow Editor](#workflow-editor).
+
+To better understand the use case and challenges related to workflows, we will review both of these modes.
+
+### Linear UI
+
+This includes the **Text to Image**, **Image to Image** and **Unified Canvas** tabs.
+
+The user-managed parameters on these tabs are stored as simple objects in the application state. When the user invokes, adding a generation to the queue, we internally build a graph from these parameters.
+
+This logic can be fairly complex due to the range of features available and their interactions. Depending on the parameters selected, the graph may be very different. Building graphs in code can be challenging - you are trying to construct a non-linear structure in a linear context.
+
+The simplest graph building logic is for **Text to Image** with a SD1.5 model: [buildLinearTextToImageGraph.ts]
+
+There are many other graph builders in the same directory for different tabs or base models (e.g. SDXL). Some are pretty hairy.
+
+In the Linear UI, we go straight from **simple application state** to **graph** via these builders.
+
+### Workflow Editor
+
+The Workflow Editor is a visual graph editor, allowing users to draw edges from node to node to construct a graph. This _far_ more approachable way to create complex graphs.
+
+InvokeAI uses the [reactflow] library to power the Workflow Editor. It provides both a graph editor UI and manages its own internal graph state.
+
+#### Workflows
+
+A workflow is a representation of a graph plus additional metadata:
+
+- Name
+- Description
+- Version
+- Notes
+- [Exposed fields](#workflow-linear-view)
+- Author, tags, category, etc.
+
+Workflows should have other qualities:
+
+- Portable: you should be able to load a workflow created by another person.
+- Resilient: you should be able to "upgrade" a workflow as the application changes.
+- Abstract: as much as is possible, workflows should not be married to the specific implementation details of the application.
+
+To support these qualities, workflows are serializable, have a versioned schemas, and represent graphs as minimally as possible. Fortunately, the reactflow state for nodes and edges works perfectly for this.
+
+##### Workflow -> reactflow state -> InvokeAI graph
+
+Given a workflow, we need to be able to derive reactflow state and/or an InvokeAI graph from it.
+
+The first step - workflow to reactflow state - is very simple. The logic is in [nodesSlice.ts], in the `workflowLoaded` reducer.
+
+The reactflow state is, however, structurally incompatible with our backend's graph structure. When a user invokes on a Workflow, we need to convert the reactflow state into an InvokeAI graph. This is far simpler than the graph building logic from the Linear UI:
+[buildNodesGraph.ts]
+
+##### Nodes vs Invocations
+
+We often use the terms "node" and "invocation" interchangeably, but they may refer to different things in the frontend.
+
+reactflow [has its own definitions][reactflow-concepts] of "node", "edge" and "handle" which are closely related to InvokeAI graph concepts.
+
+- A reactflow node is related to an InvokeAI invocation. It has a "data" property, which holds the InvokeAI-specific invocation data.
+- A reactflow edge is roughly equivalent to an InvokeAI edge.
+- A reactflow handle is roughly equivalent to an InvokeAI input or output field.
+
+##### Workflow Linear View
+
+Graphs are very capable data structures, but not everyone wants to work with them all the time.
+
+To allow less technical users - or anyone who wants a less visually noisy workspace - to benefit from the power of nodes, InvokeAI has a workflow feature called the Linear View.
+
+A workflow input field can be added to this Linear View, and its input component can be presented similarly to the Linear UI tabs. Internally, we add the field to the workflow's list of exposed fields.
+
+#### OpenAPI Schema
+
+OpenAPI is a schema specification that can represent complex data structures and relationships. The backend is capable of generating an OpenAPI schema for all invocations.
+
+When the UI connects, it requests this schema and parses each invocation into an **invocation template**. Invocation templates have a number of properties, like title, description and type, but the most important ones are their input and output **field templates**.
+
+Invocation and field templates are the "source of truth" for graphs, because they indicate what the backend is able to process.
+
+When a user adds a new node to their workflow, these templates are used to instantiate a node with fields instantiated from the input and output field templates.
+
+##### Field Instances and Templates
+
+Field templates consist of:
+
+- Name: the identifier of the field, its variable name in python
+- Type: derived from the field's type annotation in python (e.g. IntegerField, ImageField, MainModelField)
+- Constraints: derived from the field's creation args in python (e.g. minimum value for an integer)
+- Default value: optionally provided in the field's creation args (e.g. 42 for an integer)
+
+Field instances are created from the templates and have name, type and optionally a value.
+
+The type of the field determines the UI components that are rendered for it.
+
+A field instance's name associates it with its template.
+
+##### Stateful vs Stateless Fields
+
+**Stateful** fields store their value in the frontend graph. Think primitives, model identifiers, images, etc. Fields are only stateful if the frontend allows the user to directly input a value for them.
+
+Many field types, however, are **stateless**. An example is a `UNetField`, which contains some data describing a UNet. Users cannot directly provide this data - it is created and consumed in the backend.
+
+Stateless fields do not store their value in the node, so their field instances do not have values.
+
+"Custom" fields will always be treated as stateless fields.
+
+##### Single and Collection Fields
+
+Field types have a name and cardinality property which may identify it as a **SINGLE**, **COLLECTION** or **SINGLE_OR_COLLECTION** field.
+
+- If a field is annotated in python as a singular value or class, its field type is parsed as a **SINGLE** type (e.g. `int`, `ImageField`, `str`).
+- If a field is annotated in python as a list, its field type is parsed as a **COLLECTION** type (e.g. `list[int]`).
+- If it is annotated as a union of a type and list, the type will be parsed as a **SINGLE_OR_COLLECTION** type (e.g. `Union[int, list[int]]`). Fields may not be unions of different types (e.g. `Union[int, list[str]]` and `Union[int, str]` are not allowed).
+
+## Implementation
+
+The majority of data structures in the backend are [pydantic] models. Pydantic provides OpenAPI schemas for all models and we then generate TypeScript types from those.
+
+The OpenAPI schema is parsed at runtime into our invocation templates.
+
+Workflows and all related data are modeled in the frontend using [zod]. Related types are inferred from the zod schemas.
+
+> In python, invocations are pydantic models with fields. These fields become node inputs. The invocation's `invoke()` function returns a pydantic model - its output. Like the invocation itself, the output model has any number of fields, which become node outputs.
+
+### zod Schemas and Types
+
+The zod schemas, inferred types, and type guards are in [types/].
+
+Roughly order from lowest-level to highest:
+
+- `common.ts`: stateful field data, and couple other misc types
+- `field.ts`: fields - types, values, instances, templates
+- `invocation.ts`: invocations and other node types
+- `workflow.ts`: workflows and constituents
+
+We customize the OpenAPI schema to include additional properties on invocation and field schemas. To facilitate parsing this schema into templates, we modify/wrap the types from [openapi-types] in `openapi.ts`.
+
+### OpenAPI Schema Parsing
+
+The entrypoint for OpenAPI schema parsing is [parseSchema.ts].
+
+General logic flow:
+
+- Iterate over all invocation schema objects
+ - Extract relevant invocation-level attributes (e.g. title, type, version, etc)
+ - Iterate over the invocation's input fields
+ - [Parse each field's type](#parsing-field-types)
+ - [Build a field input template](#building-field-input-templates) from the type - either a stateful template or "generic" stateless template
+ - Iterate over the invocation's output fields
+ - Parse the field's type (same as inputs)
+ - [Build a field output template](#building-field-output-templates)
+ - Assemble the attributes and fields into an invocation template
+
+Most of these involve very straightforward `reduce`s, but the less intuitive steps are detailed below.
+
+#### Parsing Field Types
+
+Field types are represented as structured objects:
+
+```ts
+type FieldType = {
+ name: string;
+ cardinality: 'SINGLE' | 'COLLECTION' | 'SINGLE_OR_COLLECTION';
+};
+```
+
+The parsing logic is in `parseFieldType.ts`.
+
+There are 4 general cases for field type parsing.
+
+##### Primitive Types
+
+When a field is annotated as a primitive values (e.g. `int`, `str`, `float`), the field type parsing is fairly straightforward. The field is represented by a simple OpenAPI **schema object**, which has a `type` property.
+
+We create a field type name from this `type` string (e.g. `string` -> `StringField`). The cardinality is `"SINGLE"`.
+
+##### Complex Types
+
+When a field is annotated as a pydantic model (e.g. `ImageField`, `MainModelField`, `ControlField`), it is represented as a **reference object**. Reference objects are pointers to another schema or reference object within the schema.
+
+We need to **dereference** the schema to pull these out. Dereferencing may require recursion. We use the reference object's name directly for the field type name.
+
+> Unfortunately, at this time, we've had limited success using external libraries to deference at runtime, so we do this ourselves.
+
+##### Collection Types
+
+When a field is annotated as a list of a single type, the schema object has an `items` property. They may be a schema object or reference object and must be parsed to determine the item type.
+
+We use the item type for field type name. The cardinality is `"COLLECTION"`.
+
+##### Single or Collection Types
+
+When a field is annotated as a union of a type and list of that type, the schema object has an `anyOf` property, which holds a list of valid types for the union.
+
+After verifying that the union has two members (a type and list of the same type), we use the type for field type name, with cardinality `"SINGLE_OR_COLLECTION"`.
+
+##### Optional Fields
+
+In OpenAPI v3.1, when an object is optional, it is put into an `anyOf` along with a primitive schema object with `type: 'null'`.
+
+Handling this adds a fair bit of complexity, as we now must filter out the `'null'` types and work with the remaining types as described above.
+
+If there is a single remaining schema object, we must recursively call to `parseFieldType()` to get parse it.
+
+#### Building Field Input Templates
+
+Now that we have a field type, we can build an input template for the field.
+
+Stateful fields all get a function to build their template, while stateless fields are constructed directly. This is possible because stateless fields have no default value or constraints.
+
+See [buildFieldInputTemplate.ts].
+
+#### Building Field Output Templates
+
+Field outputs are similar to stateless fields - they do not have any value in the frontend. When building their templates, we don't need a special function for each field type.
+
+See [buildFieldOutputTemplate.ts].
+
+### Managing reactflow State
+
+As described above, the workflow editor state is the essentially the reactflow state, plus some extra metadata.
+
+We provide reactflow with an array of nodes and edges via redux, and a number of [event handlers][reactflow-events]. These handlers dispatch redux actions, managing nodes and edges.
+
+The pieces of redux state relevant to workflows are:
+
+- `state.nodes.nodes`: the reactflow nodes state
+- `state.nodes.edges`: the reactflow edges state
+- `state.nodes.workflow`: the workflow metadata
+
+#### Building Nodes and Edges
+
+A reactflow node has a few important top-level properties:
+
+- `id`: unique identifier
+- `type`: a string that maps to a react component to render the node
+- `position`: XY coordinates
+- `data`: arbitrary data
+
+When the user adds a node, we build **invocation node data**, storing it in `data`. Invocation properties (e.g. type, version, label, etc.) are copied from the invocation template. Inputs and outputs are built from the invocation template's field templates.
+
+See [buildInvocationNode.ts].
+
+Edges are managed by reactflow, but briefly, they consist of:
+
+- `source`: id of the source node
+- `sourceHandle`: id of the source node handle (output field)
+- `target`: id of the target node
+- `targetHandle`: id of the target node handle (input field)
+
+> Edge creation is gated behind validation logic. This validation compares the input and output field types and overall graph state.
+
+#### Building a Workflow
+
+Building a workflow entity is as simple as dropping the nodes, edges and metadata into an object.
+
+Each node and edge is parsed with a zod schema, which serves to strip out any unneeded data.
+
+See [buildWorkflow.ts].
+
+#### Loading a Workflow
+
+Workflows may be loaded from external sources or the user's local instance. In all cases, the workflow needs to be handled with care, as an untrusted object.
+
+Loading has a few stages which may throw or warn if there are problems:
+
+- Parsing the workflow data structure itself, [migrating](#workflow-migrations) it if necessary (throws)
+- Check for a template for each node (warns)
+- Check each node's version against its template (warns)
+- Validate the source and target of each edge (warns)
+
+This validation occurs in [validateWorkflow.ts].
+
+If there are no fatal errors, the workflow is then stored in redux state.
+
+### Workflow Migrations
+
+When the workflow schema changes, we may need to perform some data migrations. This occurs as workflows are loaded. zod schemas for each workflow schema version is retained to facilitate migrations.
+
+Previous schemas are in folders in `invokeai/frontend/web/src/features/nodes/types/`, eg `v1/`.
+
+Migration logic is in [migrations.ts].
+
+[pydantic]: https://github.com/pydantic/pydantic 'pydantic'
+[zod]: https://github.com/colinhacks/zod 'zod'
+[openapi-types]: https://github.com/kogosoftwarellc/open-api/tree/main/packages/openapi-types 'openapi-types'
+[reactflow]: https://github.com/xyflow/xyflow 'reactflow'
+[reactflow-concepts]: https://reactflow.dev/learn/concepts/terms-and-definitions
+[reactflow-events]: https://reactflow.dev/api-reference/react-flow#event-handlers
+[buildWorkflow.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/buildWorkflow.ts
+[nodesSlice.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+[buildLinearTextToImageGraph.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearTextToImageGraph.ts
+[buildNodesGraph.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts
+[buildInvocationNode.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/node/buildInvocationNode.ts
+[validateWorkflow.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/validateWorkflow.ts
+[migrations.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts
+[parseSchema.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/parseSchema.ts
+[buildFieldInputTemplate.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts
+[buildFieldOutputTemplate.ts]: https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldOutputTemplate.ts
diff --git a/docs/src/content/docs/development/Guides/api-development.mdx b/docs/src/content/docs/development/Guides/api-development.mdx
new file mode 100644
index 00000000000..3911dad0081
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/api-development.mdx
@@ -0,0 +1,50 @@
+---
+title: API Development
+---
+
+import InvocationContextDocs from '@lib/components/InvocationContextDocs.astro'
+
+Each invocation's `invoke` method is provided a single arg - the Invocation Context.
+
+This object provides an API the invocation can use to interact with application services, for example:
+
+- Saving images
+- Logging messages
+- Loading models
+
+```py
+class MyInvocation(BaseInvocation):
+ ...
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ # Load an image
+ image_pil = context.images.get_pil(self.image.image_name)
+ # Do something to the image
+ output_image = do_something_cool(image_pil)
+ # Save the image
+ image_dto = context.images.save(output_image)
+ # Log a message
+ context.logger.info(f"Did something cool, image saved!")
+ # Return the output
+ return ImageOutput.build(image_dto)
+ ...
+```
+
+The full generated API reference is documented below.
+
+## Mixins
+
+Two important mixins are provided to facilitate working with metadata and gallery boards.
+
+### `WithMetadata`
+
+Inherit from this class (in addition to `BaseInvocation`) to add a `metadata` input to your node. When you do this, you can access the metadata dict from `self.metadata` in the `invoke()` function.
+
+The dict will be populated via the node's input, and you can add any metadata you'd like to it. When you call `context.images.save()`, if the metadata dict has any data, it be automatically embedded in the image.
+
+### `WithBoard`
+
+Inherit from this class (in addition to `BaseInvocation`) to add a `board` input to your node. This renders as a drop-down to select a board. The user's selection will be accessible from `self.board` in the `invoke()` function.
+
+When you call `context.images.save()`, if a board was selected, the image will added to that board as it is saved.
+
+
diff --git a/docs/assets/contributing/html-detail.png b/docs/src/content/docs/development/Guides/assets/html-detail.png
similarity index 100%
rename from docs/assets/contributing/html-detail.png
rename to docs/src/content/docs/development/Guides/assets/html-detail.png
diff --git a/docs/assets/contributing/html-overview.png b/docs/src/content/docs/development/Guides/assets/html-overview.png
similarity index 100%
rename from docs/assets/contributing/html-overview.png
rename to docs/src/content/docs/development/Guides/assets/html-overview.png
diff --git a/docs/src/content/docs/development/Guides/creating-node-pack.mdx b/docs/src/content/docs/development/Guides/creating-node-pack.mdx
new file mode 100644
index 00000000000..01a51afbcb5
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/creating-node-pack.mdx
@@ -0,0 +1,157 @@
+---
+title: Creating Node Packs
+lastUpdated: 2026-05-23
+---
+
+import { FileTree } from '@astrojs/starlight/components'
+
+This guide explains how to structure your Git repository so it can be installed via InvokeAI's Custom Node Manager.
+
+## Repository Structure
+
+Your repository **is** the node pack. When a user installs it, the entire repo is cloned into the `nodes` directory.
+
+### Minimum Required Structure
+
+
+ - my-node-pack/
+ - `__init__.py` Required: Imports all node classes
+ - my_node.py Your node implementation(s)
+ - README.md Recommended: Describe how your nodes work
+
+
+The `__init__.py` at the root is **mandatory**. Without it, the pack will not be loaded.
+
+### Recommended Structure
+
+
+ - my-node-pack/
+ - `__init__.py` Required: Imports all node classes
+ - requirements.txt Python dependencies (user-installed)
+ - README.md Description, usage & examples
+ - node_one.py Node implementation
+ - node_two.py Node implementation
+ - utils.py Shared utilities
+ - workflows/ Optional: Included workflow files
+ - example_workflow.json
+ - advanced_workflow.json
+
+
+## The `__init__.py` File
+
+This file must import all invocation classes you want to register. Only classes imported here will be available in InvokeAI.
+
+```python title="__init__.py"
+from .node_one import MyFirstInvocation
+from .node_two import MySecondInvocation
+```
+
+If you have nodes in subdirectories:
+
+```python
+from .nodes.image_tools import CropInvocation, ResizeInvocation
+from .nodes.text_tools import ConcatInvocation
+```
+
+## Dependencies (`requirements.txt` or `pyproject.toml`)
+
+If your nodes require additional Python packages, list them in a `requirements.txt` (or `pyproject.toml`) at the repository root:
+
+```txt title="requirements.txt"
+numpy>=1.24
+opencv-python>=4.8
+```
+
+The Custom Node Manager **does not** install these dependencies automatically — auto-installing into the running InvokeAI environment risks pulling in incompatible versions and breaking the application. After install, the UI shows the user a toast telling them that manual installation is required, and your README should document the exact install command (e.g. `pip install -r requirements.txt` from inside an activated InvokeAI environment).
+
+**Important:** Avoid pinning versions too tightly. InvokeAI has its own dependencies, and version conflicts can cause issues. Use minimum version constraints (`>=`) where possible.
+
+## Including Workflows
+
+If your repository contains workflow `.json` files, they will be **automatically imported** into the user's workflow library during installation.
+
+### Workflow Detection
+
+The installer recursively scans your repository for `.json` files. A file is recognized as a workflow if it contains both `nodes` and `edges` keys at the top level.
+
+### Tagging
+
+Imported workflows are automatically tagged with `node-pack:` so users can filter for them in the workflow library. When the node pack is uninstalled, these workflows are also removed.
+
+### Workflow Format
+
+Workflows should follow the standard InvokeAI workflow format:
+
+```json title="example_workflow.json"
+{
+ "name": "My Example Workflow",
+ "author": "Your Name",
+ "description": "Demonstrates how to use MyFirstInvocation",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "example, my-node-pack",
+ "notes": "",
+ "meta": {
+ "version": "3.0.0",
+ "category": "user"
+ },
+ "exposedFields": [],
+ "nodes": [...],
+ "edges": [...]
+}
+```
+
+**Tip:** The easiest way to create a workflow file is to build the workflow in InvokeAI's workflow editor, then export it via **Save As** and copy the `.json` file into your repository.
+
+## Node Implementation
+
+Each node is a Python class decorated with `@invocation()`. Here's a minimal example:
+
+```python title="example_node.py"
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField, OutputField
+from invokeai.invocation_api import BaseInvocationOutput, invocation_output
+
+@invocation_output("my_output")
+class MyOutput(BaseInvocationOutput):
+ result: str = OutputField(description="The result")
+
+@invocation(
+ "my_node",
+ title="My Node",
+ tags=["example", "custom"],
+ category="custom",
+ version="1.0.0",
+)
+class MyInvocation(BaseInvocation):
+ """Does something useful."""
+
+ input_text: str = InputField(default="", description="Input text")
+
+ def invoke(self, context) -> MyOutput:
+ return MyOutput(result=f"Processed: {self.input_text}")
+```
+
+For full details on the invocation API, see the [Invocation API documentation](invocation-api.md).
+
+## Best Practices
+
+- **Use a descriptive repository name** — it becomes the pack name shown in the UI
+- **Include a README.md** with description, screenshots, and usage instructions
+- **Version your nodes** using semver in the `@invocation()` decorator
+- **Don't include large binary files** in your repository (models, weights, etc.)
+- **Test your nodes** by placing the repo in the `nodes` directory before publishing
+- **Include example workflows** so users can get started quickly
+- **Tag your GitHub repository** with `invokeai-node` for discoverability
+- **Avoid name collisions** — choose unique invocation type strings (e.g. `my_pack_resize` instead of just `resize`)
+
+## Testing Your Pack
+
+Before publishing, verify your pack works with the Custom Node Manager:
+
+1. Create a Git repository with your node pack
+2. Push it to GitHub (or any Git host)
+3. In InvokeAI, go to the Nodes tab and install it via the Git URL
+4. Verify your nodes appear in the workflow editor
+5. Verify any included workflows are imported
+6. Test uninstalling — nodes and workflows should be removed
diff --git a/docs/src/content/docs/development/Guides/creating-nodes.mdx b/docs/src/content/docs/development/Guides/creating-nodes.mdx
new file mode 100644
index 00000000000..abc905f6e6a
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/creating-nodes.mdx
@@ -0,0 +1,75 @@
+---
+title: Creating Nodes
+---
+
+import { Steps, LinkCard } from '@astrojs/starlight/components';
+
+
+ 1. Learn about the specifics of creating a new node in our Node Creation Documentation.
+
+
+
+ 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](../../../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.
+
+ 4. A maintainer will review the pull request and node. If the node is aligned with the direction of the project, you may be asked for permission to include it in the core project.
+
+
+### Supporting multi-GPU text-encoder offload
+
+On a machine with more than one GPU, InvokeAI can run several generation sessions at once — one per GPU. When fewer sessions are running than there are GPUs, the spare GPUs sit idle. To put that capacity to use, InvokeAI can run a session's **prompt/text encoder** on a currently-idle GPU instead of on the GPU running the denoise pipeline. This avoids evicting the denoise model from VRAM just to make room for the encoder, and lets the cached encoder be reused across generations.
+
+This is controlled globally by the `offload_text_encoders_to_idle_gpus` config setting (enabled by default) and opted into **per node** via the `@invocation` decorator:
+
+```python
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+
+
+@invocation(
+ "my_text_encoder",
+ title="Prompt - My Model",
+ category="conditioning",
+ version="1.0.0",
+ idle_gpu_offloadable=True, # opt in to idle-GPU offload
+)
+class MyTextEncoderInvocation(BaseInvocation):
+ ...
+```
+
+When the feature is enabled and an idle GPU is available, the **entire node** is temporarily re-pinned to a borrowed idle GPU: any model it loads goes onto that GPU and runs there. If no idle GPU is free (e.g. every GPU is busy with its own session), the node simply runs on its own GPU, unchanged. The borrow holds the idle GPU exclusively for the duration of the node, so it can never run concurrently against a native session on that same GPU.
+
+Because the whole node is moved to another device, only mark a node `idle_gpu_offloadable=True` if **all** of the following hold:
+
+- **It is encoder-only.** Its sole GPU work is loading one or more encoder models and running their forward pass. It must not load or run the denoise/transformer or VAE, or do any other work tied to the session's own GPU.
+- **It stores its result on the CPU before returning.** Move output tensors to the CPU (`tensor.detach().to("cpu")`) and save them as conditioning/tensors. The denoiser picks them up and moves them onto its own GPU later — this is what makes the cross-GPU handoff safe and device-agnostic.
+- **It places inputs on the loaded model's device, not a fixed device.** Resolve the device from the model you just loaded (e.g. `get_effective_device(model)` from `invokeai.backend.model_manager.load.model_cache.utils`, or `TorchDevice.choose_torch_device()`), rather than hard-coding `cuda:0`. The built-in `flux_text_encoder` and `compel` nodes are good references.
+
+:::caution[Only mark encoder-only nodes]
+If a node that also runs the denoiser, VAE, or other session-GPU work is marked `idle_gpu_offloadable=True`, that work will be re-pinned to the wrong GPU and can misplace tensors or raise device-mismatch errors. When in doubt, leave it unset (the default is `False`) — the node will still work correctly, just without the offload optimization.
+:::
+
+### 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:
+
+```md
+---
+
+### Super Cool Node Template
+
+**Description:** This node allows you to do super cool things with InvokeAI.
+
+**Node Link:** https://github.com/invoke-ai/InvokeAI/fake_node.py
+
+**Example Node Graph:** https://github.com/invoke-ai/InvokeAI/fake_node_graph.json
+
+**Output Examples**
+
+
+```
diff --git a/docs/src/content/docs/development/Guides/models.mdx b/docs/src/content/docs/development/Guides/models.mdx
new file mode 100644
index 00000000000..8657cc97818
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/models.mdx
@@ -0,0 +1,556 @@
+---
+title: Integrating a New Model Architecture
+description: A comprehensive guide to integrating new foundational model architectures into InvokeAI.
+lastUpdated: 2026-02-19
+---
+
+import { Steps, FileTree } from '@astrojs/starlight/components';
+
+This guide walks you through the end-to-end process of integrating a **new foundational model architecture** into InvokeAI. This is required when adding a completely new family of models (e.g., Stable Diffusion 3, FLUX, Hunyuan, etc.), rather than just adding a new checkpoint for an existing architecture.
+
+:::note
+The code examples in this guide use a hypothetical `NewModel` architecture. The implementations of `FLUX`, `SD3`, and `SDXL` in the InvokeAI codebase serve as excellent real-world references.
+:::
+
+## Architectural Overview
+
+Integrating a new model touches several parts of the InvokeAI stack, from the lowest-level PyTorch inference code up to the React frontend:
+
+1. **Taxonomy & Configuration (Backend)**: Declaring the model's existence and defining how to detect it from its weights on disk.
+2. **Model Loading (Backend)**: Defining how to load the detected files into PyTorch models in memory.
+3. **Sampling & Denoising (Backend)**: Implementing the core math for noise generation, scheduling, and the denoising loop.
+4. **Invocations (Backend)**: Wrapping the PyTorch logic into isolated "nodes" that can be executed by InvokeAI's graph engine.
+5. **Graph Building (Frontend)**: Instructing the UI on how to wire these nodes together based on user settings.
+6. **State & UI (Frontend)**: Adding the necessary UI controls and state management for the new model's unique parameters.
+
+---
+
+## 1. Taxonomy & Defaults
+
+The first step is to declare your model in the system's taxonomy and provide reasonable default settings.
+
+
+1. **Add `BaseModelType`**
+
+ Update the base model taxonomy to include your new model.
+
+ ```python title="invokeai/backend/model_manager/taxonomy.py" ins={7}
+ class BaseModelType(str, Enum):
+ # Existing types
+ StableDiffusion1 = "sd-1"
+ StableDiffusion2 = "sd-2"
+ StableDiffusionXL = "sdxl"
+ Flux = "flux"
+ NewModel = "newmodel"
+ ```
+
+2. **Add Variant Type (if needed)**
+
+ If your model comes in different structural variants (e.g., different parameter counts or distilled versions like `schnell` vs `dev`), define a variant enum.
+
+ ```python title="invokeai/backend/model_manager/taxonomy.py"
+ class NewModelVariantType(str, Enum):
+ VariantA = "variant_a"
+ VariantB = "variant_b"
+ ```
+
+3. **Define Default Settings**
+
+ Provide default generation parameters (steps, CFG scale, etc.) for the UI to use when this model is selected.
+
+ ```python title="invokeai/backend/model_manager/configs/main.py" ins={5-6}
+ class MainModelDefaultSettings:
+ @staticmethod
+ def from_base(base: BaseModelType, variant: AnyVariant | None = None):
+ match base:
+ case BaseModelType.NewModel:
+ return MainModelDefaultSettings(steps=20, cfg_scale=7.0)
+ ```
+
+
+:::tip[Checklist: Taxonomy]{icon="approve-check"}
+ - [ ] Extend `BaseModelType` enum in `taxonomy.py`
+ - [ ] Create variant enum if needed in `taxonomy.py`
+ - [ ] Update `AnyVariant` union in `taxonomy.py`
+ - [ ] Add default settings in `from_base()` in `configs/main.py`
+:::
+
+---
+
+## 2. Model Configs & Detection
+
+InvokeAI needs to know how to identify your model from a `.safetensors` file or a diffusers folder.
+
+
+1. **Create Main Model Config**
+
+ Define the configuration schemas for your model format(s).
+
+ ```python title="invokeai/backend/model_manager/configs/main.py"
+ # Checkpoint Format (Single File)
+ @ModelConfigFactory.register
+ class Main_Checkpoint_NewModel_Config(Checkpoint_Config_Base):
+ type: Literal[ModelType.Main] = ModelType.Main
+ base: Literal[BaseModelType.NewModel] = BaseModelType.NewModel
+ format: Literal[ModelFormat.Checkpoint] = ModelFormat.Checkpoint
+ variant: NewModelVariantType = NewModelVariantType.VariantA
+
+ @classmethod
+ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict) -> Self:
+ if not cls._validate_is_newmodel(mod):
+ raise NotAMatchError("Not a NewModel")
+ variant = cls._get_variant_or_raise(mod)
+ return cls(..., variant=variant)
+
+ # Diffusers Format (Folder)
+ @ModelConfigFactory.register
+ class Main_Diffusers_NewModel_Config(Diffusers_Config_Base):
+ type: Literal[ModelType.Main] = ModelType.Main
+ base: Literal[BaseModelType.NewModel] = BaseModelType.NewModel
+ format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers
+ ```
+
+2. **Implement Detection Logic**
+
+ Write helper functions to inspect the state dictionary keys and shape to uniquely identify your architecture.
+
+ ```python title="invokeai/backend/model_manager/configs/main.py"
+ def _is_newmodel(state_dict: dict) -> bool:
+ """Detect if state dict belongs to NewModel architecture."""
+ # Example: check for a highly specific layer name or shape
+ required_keys = ["transformer_blocks.0.attn.to_q.weight"]
+ return all(key in state_dict for key in required_keys)
+
+ def _get_newmodel_variant(state_dict: dict) -> NewModelVariantType:
+ """Determine variant from state dict."""
+ # Example: distinguish variants based on hidden dimension size
+ context_dim = state_dict["context_embedder.weight"].shape[1]
+ if context_dim == 7680:
+ return NewModelVariantType.VariantA
+ return NewModelVariantType.VariantB
+ ```
+
+3. **Submodels (VAE & Text Encoder)**
+
+ If your model uses a novel VAE or Text Encoder not already in InvokeAI, you must repeat this process to create configs for them (e.g., in `configs/vae.py` and `configs/[encoder_type].py`).
+
+4. **Update the Configuration Union**
+
+ Register your new configs so the application knows to check them when scanning directories.
+
+ ```python title="invokeai/backend/model_manager/configs/factory.py" ins={4-5}
+ AnyModelConfig = Annotated[
+ # ... existing configs
+ Main_Checkpoint_NewModel_Config |
+ Main_Diffusers_NewModel_Config,
+ Discriminator(...)
+ ]
+ ```
+
+
+:::tip[Checklist: Configs]{icon="approve-check"}
+ - [ ] Create main checkpoint config (`configs/main.py`)
+ - [ ] Create main diffusers config (`configs/main.py`)
+ - [ ] Create detection helper functions (`_is_newmodel()`, `_get_variant()`)
+ - [ ] Create VAE and Text Encoder configs if they use novel architectures
+ - [ ] Update `AnyModelConfig` union (`configs/factory.py`)
+:::
+
+---
+
+## 3. Model Loaders
+
+Loaders are responsible for converting the files on disk (described by the config) into PyTorch models in memory.
+
+
+1. **Create the Model Loader**
+
+ ```python title="invokeai/backend/model_manager/load/model_loaders/[newmodel].py"
+ @ModelLoaderRegistry.register(
+ base=BaseModelType.NewModel,
+ type=ModelType.Main,
+ format=ModelFormat.Checkpoint
+ )
+ class NewModelLoader(ModelLoader):
+ def _load_model(self, config: AnyModelConfig, submodel_type: SubModelType | None) -> AnyModel:
+ # 1. Load the raw weights from disk
+ state_dict = self._load_state_dict(config.path)
+
+ # 2. Convert state dict keys if necessary (e.g. from original repo format to Diffusers)
+ if self._is_original_format(state_dict):
+ state_dict = self._convert_to_diffusers_format(state_dict)
+
+ # 3. Instantiate the empty PyTorch model
+ model = NewModelTransformer(config=model_config)
+
+ # 4. Load weights into the model
+ model.load_state_dict(state_dict)
+ return model
+ ```
+
+2. **Custom VAE/Encoder Loaders (If Applicable)**
+
+ If you created custom configs for the VAE or Text Encoder, you must also create loaders for them, registering them with the appropriate `ModelType`.
+
+
+:::tip[Checklist: Loaders]{icon="approve-check"}
+- [ ] Create and register the main model loader
+- [ ] Create VAE/Encoder loaders if necessary
+- [ ] Implement state dict conversion if supporting non-diffusers formats
+:::
+
+---
+
+## 4. Sampling and Denoising Core
+
+This is where the actual mathematical implementation of the model lives.
+
+
+
+1. **Sampling Utilities**
+
+ Create utility functions specific to how your model handles noise, packing, and scheduling.
+
+ ```python title="invokeai/backend/[newmodel]/sampling_utils.py"
+ def get_noise_newmodel(num_samples: int, height: int, width: int, seed: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
+ # Models often have different latent channel counts (e.g., SD1.5 has 4, FLUX has 16)
+ latent_channels = 32
+ latent_h, latent_w = height // 8, width // 8
+ generator = torch.Generator(device=device).manual_seed(seed)
+ return torch.randn((num_samples, latent_channels, latent_h, latent_w), generator=generator, device=device, dtype=dtype)
+
+ def pack_newmodel(x: torch.Tensor) -> torch.Tensor:
+ # Some transformer-based models require packing latents into a sequence
+ return rearrange(x, "b c (h ph) (w pw) -> b (h w) (c ph pw)", ph=2, pw=2)
+ ```
+
+ If the architecture supports external noise, prefer extending the standard
+ `invokeai/app/invocations/noise.py` node's `noise_type` selector instead of
+ adding a brand new noise node. Only add a dedicated noise invocation when the
+ architecture's noise tensor rank or layout cannot be expressed by the
+ standard node.
+
+2. **The Denoising Loop**
+
+ Implement the core sampling loop. This interacts with schedulers and handles classifier-free guidance (CFG).
+
+ ```python title="invokeai/backend/[newmodel]/denoise.py"
+ def denoise(model: nn.Module, img: torch.Tensor, txt: torch.Tensor, timesteps: list[float], cfg_scale: list[float], scheduler: Any = None) -> torch.Tensor:
+ """Main denoising loop."""
+ total_steps = len(timesteps) - 1
+
+ for step_index in range(total_steps):
+ t_curr = timesteps[step_index]
+
+ # Handle CFG (Classifier-Free Guidance)
+ if cfg_scale[step_index] > 1.0:
+ # Batch positive and negative prompts if applicable
+ pred_pos = model(img, t_curr, txt)
+ # ...
+ else:
+ pred = model(img, t_curr, txt)
+
+ # Step the scheduler
+ img = scheduler.step(pred, t_curr, img).prev_sample
+
+ return img
+ ```
+
+3. **Schedulers**
+
+ If your model requires a novel scheduler, add it to the scheduler mapping (e.g., `invokeai/backend/[newmodel]/schedulers.py`).
+
+
+:::tip[Checklist: Core Inference]{icon="approve-check"}
+ - [ ] Noise generation (`get_noise_newmodel()`)
+ - [ ] Pack/unpack functions (if transformer-based)
+ - [ ] Timestep schedule generation
+ - [ ] Denoise loop implementation
+ - [ ] Map supported schedulers
+:::
+
+---
+
+## 5. Invocations
+
+Invocations expose your PyTorch functions as isolated execution nodes in InvokeAI's graph.
+
+
+1. **Model Loader Invocation**
+
+ Loads the components (Transformer, VAE, etc.) and provides them to downstream nodes.
+
+ ```python title="invokeai/app/invocations/[newmodel]_model_loader.py"
+ @invocation("newmodel_model_loader", title="NewModel Loader", category="model_loader")
+ class NewModelModelLoaderInvocation(BaseInvocation):
+ model: ModelIdentifierField = InputField(description="Main model")
+
+ def invoke(self, context: InvocationContext) -> NewModelLoaderOutput:
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ return NewModelLoaderOutput(transformer=transformer, vae=vae)
+ ```
+
+2. **Text Encoder Invocation**
+
+ Tokenizes the prompt and runs the text encoder(s).
+
+ ```python title="invokeai/app/invocations/[newmodel]_text_encoder.py"
+ @invocation("newmodel_text_encode", title="NewModel Text Encoder", category="conditioning")
+ class NewModelTextEncoderInvocation(BaseInvocation):
+ prompt: str = InputField()
+ encoder: EncoderField = InputField()
+
+ def invoke(self, context: InvocationContext) -> ConditioningOutput:
+ # 1. Tokenize prompt
+ # 2. Run encoder to get embeddings
+ # 3. Save to context and return
+ conditioning_name = context.conditioning.save(ConditioningFieldData(...))
+ return ConditioningOutput(conditioning=ConditioningField(conditioning_name=conditioning_name))
+ ```
+
+3. **Denoise Invocation**
+
+ Wraps the `denoise` loop you wrote in the previous section.
+
+ ```python title="invokeai/app/invocations/[newmodel]_denoise.py"
+ @invocation("newmodel_denoise", title="NewModel Denoise", category="latents")
+ class NewModelDenoiseInvocation(BaseInvocation):
+ latents: LatentsField | None = InputField(default=None)
+ noise: LatentsField | None = InputField(default=None)
+ positive_conditioning: ConditioningField = InputField()
+ transformer: TransformerField = InputField()
+ steps: int = InputField(default=20)
+ cfg_scale: float = InputField(default=7.0)
+
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ # Generate noise, get schedule, and call your denoise() function
+ pass
+ ```
+
+ If you add external noise support, keep it optional so seed-driven workflows
+ continue to work. Validate connected noise against the architecture's
+ expected shape before using it.
+
+4. **VAE Encode / Decode Invocations**
+
+ Create nodes to transition between pixel space (images) and latent space.
+
+
+:::tip[Checklist: Invocations]{icon="approve-check"}
+ - [ ] Define output classes (e.g., `NewModelLoaderOutput`)
+ - [ ] Model loader invocation (`[newmodel]_model_loader.py`)
+ - [ ] Text encoder invocation (`[newmodel]_text_encoder.py`)
+ - [ ] Denoise invocation (`[newmodel]_denoise.py`)
+ - [ ] Extend the standard `noise` invocation if the architecture supports external noise
+ - [ ] VAE encode/decode invocations (`[newmodel]_vae_encode.py`, `[newmodel]_vae_decode.py`)
+:::
+
+---
+
+## 6. Frontend: Graph Building
+
+The UI doesn't know about Python functions; it only knows how to build graphs of Invocations.
+
+
+1. **Create the Graph Builder**
+
+ Write a TypeScript function that constructs the node graph for your model.
+
+ ```typescript title="invokeai/frontend/web/src/features/nodes/util/graph/generation/buildNewModelGraph.ts"
+ export const buildNewModelGraph = async (arg: GraphBuilderArg): Promise => {
+ const { state, manager } = arg;
+ const { model } = state.params;
+ const g = new Graph();
+
+ // 1. Add Loader
+ const modelLoader = g.addNode({
+ id: NEWMODEL_MODEL_LOADER,
+ type: 'newmodel_model_loader',
+ model: Graph.getModelMetadataField(model),
+ });
+
+ // 2. Add Text Encoders
+ const positivePrompt = g.addNode({
+ id: POSITIVE_CONDITIONING,
+ type: 'newmodel_text_encode',
+ prompt: state.params.positivePrompt,
+ });
+ g.addEdge(modelLoader, 'encoder', positivePrompt, 'encoder');
+
+ // 3. Add Denoise
+ const denoise = g.addNode({
+ id: NEWMODEL_DENOISE,
+ type: 'newmodel_denoise',
+ steps: state.params.steps,
+ cfg_scale: state.params.cfg,
+ });
+ g.addEdge(modelLoader, 'transformer', denoise, 'transformer');
+ g.addEdge(positivePrompt, 'conditioning', denoise, 'positive_conditioning');
+
+ // 4. Add VAE Decode
+ const l2i = g.addNode({
+ id: NEWMODEL_VAE_DECODE,
+ type: 'newmodel_vae_decode',
+ });
+ g.addEdge(modelLoader, 'vae', l2i, 'vae');
+ g.addEdge(denoise, 'latents', l2i, 'latents');
+
+ return { g, denoise, posCond: positivePrompt };
+ };
+ ```
+
+2. **Register the Graph Builder**
+
+ Hook your graph builder into the main routing logic.
+
+ ```typescript title="invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts" ins={5-6}
+ switch (base) {
+ case 'sdxl':
+ return buildSDXLGraph(arg);
+ case 'flux':
+ return buildFLUXGraph(arg);
+ case 'newmodel':
+ return buildNewModelGraph(arg);
+ }
+ ```
+
+3. **Update Type Definitions**
+
+ Add your new nodes to the strict frontend type unions.
+
+ ```typescript title="invokeai/frontend/web/src/features/nodes/util/graph/types.ts" ins="| 'newmodel_vae_decode'"
+ export type ImageOutputNodes =
+ | 'l2i' | 'flux_vae_decode' | 'sd3_l2i' | 'newmodel_vae_decode';
+ ```
+
+4. **Generation Modes**
+
+ Update `invokeai/app/invocations/metadata.py` to include your new modes in `GENERATION_MODES` (e.g., `"newmodel_txt2img"`, `"newmodel_img2img"`).
+
+
+:::tip[Checklist: Graph Building]{icon="approve-check"}
+ - [ ] Create graph builder (`buildNewModelGraph.ts`)
+ - [ ] Register graph builder in `useEnqueueCanvas.ts`
+ - [ ] Update node unions in `types.ts`
+ - [ ] Add generation modes to python `metadata.py`
+:::
+
+---
+
+## 7. Frontend: State & UI
+
+Finally, add any custom UI controls (like a specific scheduler dropdown) and manage their state.
+
+
+1. **Add to Redux State**
+
+ Update the parameters slice for your model-specific settings.
+
+ ```typescript title="invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts"
+ interface ParamsState {
+ // ...
+ newmodelScheduler: 'euler' | 'heun';
+ }
+
+ const initialState: ParamsState = {
+ // ...
+ newmodelScheduler: 'euler',
+ };
+
+ // Add reducers and export selectors...
+ ```
+
+2. **Parameter Recall**
+
+ Ensure users can extract parameters from previously generated images by updating `invokeai/frontend/web/src/features/metadata/parsing.tsx`.
+
+ ```typescript title="invokeai/frontend/web/src/features/metadata/parsing.tsx"
+ const recallNewmodelScheduler = (metadata: CoreMetadata) => {
+ if (metadata.scheduler) {
+ dispatch(setNewmodelScheduler(metadata.scheduler));
+ }
+ };
+ ```
+
+
+:::tip[Checklist: State & UI]{icon="approve-check"}
+ - [ ] Extend state interface for model-specific parameters
+ - [ ] Create reducers and selectors
+ - [ ] Add parameter recall handlers in `parsing.tsx`
+:::
+
+---
+
+## 8. Optional Features
+
+Depending on the model, you may want to support additional features.
+
+### ControlNet Support
+Requires backend configuration (`configs/controlnet.py`), a custom invocation (`[newmodel]_controlnet.py`), and frontend graph integration (`addControlNets`).
+
+### LoRA Support
+Requires defining a LoRA config (`configs/lora.py`), updating the model loader to pass LoRA fields, and wiring `addLoRAs` in the frontend graph builder.
+
+### IP-Adapter
+Requires a custom invocation for image prompting (`[newmodel]_ip_adapter.py`) and frontend integration via `addIPAdapters`.
+
+---
+
+## 9. Starter Models
+
+To allow users to easily download your model from the Model Manager UI, add it to the starter models list.
+
+```python title="invokeai/backend/model_manager/starter_models.py"
+newmodel_main = StarterModel(
+ name="NewModel Main",
+ base=BaseModelType.NewModel,
+ source="organization/newmodel-main", # HuggingFace repo
+ description="NewModel main transformer.",
+ type=ModelType.Main,
+)
+
+STARTER_MODELS.append(newmodel_main)
+```
+
+:::tip[Checklist: Starter Models]{icon="approve-check"}
+- [ ] Define main model StarterModel
+- [ ] Define VAE/Encoder StarterModels if separate
+- [ ] Set dependencies correctly if required
+- [ ] Add to `STARTER_MODELS` list
+:::
+
+---
+
+## Summary of Integration Files
+
+A complete minimal `txt2img` integration touches the following areas:
+
+
+- invokeai
+ - app/invocations
+ - metadata.py
+ - `[newmodel]_model_loader.py`
+ - `[newmodel]_text_encoder.py`
+ - `[newmodel]_denoise.py`
+ - `[newmodel]_vae_decode.py`
+ - backend
+ - model_manager
+ - taxonomy.py
+ - configs
+ - main.py
+ - factory.py
+ - load/model_loaders
+ - `[newmodel].py`
+ - starter_models.py
+ - `[newmodel]`
+ - sampling_utils.py
+ - denoise.py
+ - frontend/web/src/features
+ - nodes/util/graph
+ - generation/buildNewModelGraph.ts
+ - types.ts
+ - queue/hooks/useEnqueueCanvas.ts
+ - controlLayers/store/paramsSlice.ts
+ - metadata/parsing.tsx
+
diff --git a/docs/src/content/docs/development/Guides/recall-api.mdx b/docs/src/content/docs/development/Guides/recall-api.mdx
new file mode 100644
index 00000000000..f376e2af4bd
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/recall-api.mdx
@@ -0,0 +1,572 @@
+---
+title: Recall Parameters API
+---
+
+## Overview
+
+The Recall Parameters API is a REST endpoint on the InvokeAI backend that
+lets external processes set recallable generation parameters on the
+frontend. Supported parameters include:
+
+- Core text and numeric parameters (prompts, model, steps, CFG, dimensions, seed, ...)
+- LoRAs
+- Control Layers (ControlNet, T2I Adapter, Control LoRA) with optional control images
+- IP Adapters and FLUX Redux reference images with optional images
+- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
+
+When parameters are updated via the API, the backend stores them in client
+state persistence for the target queue and broadcasts a `recall_parameters_updated`
+WebSocket event. Any frontend client subscribed to that queue applies the
+new values immediately — no manual reload required.
+
+Typical use cases:
+
+- An external image browser that wants to "recall" or "remix" the
+ generation parameters saved into a PNG's metadata.
+- A script that pre-populates parameters before the user runs generation.
+- Automated testing or batch workflows that want to reuse existing model
+ and adapter configurations.
+
+## How It Works
+
+1. **API request** — your client POSTs a JSON body of parameters to
+ `/api/v1/recall/{queue_id}`.
+2. **Storage** — non-null parameters are stored under
+ `recall_*` keys in the client state persistence service, scoped to the
+ given `queue_id`.
+3. **Resolution** — models are resolved from human-readable names to the
+ internal model keys used by the frontend, and image filenames are
+ validated against `{INVOKEAI_ROOT}/outputs/images`.
+4. **Broadcast** — a `recall_parameters_updated` event is emitted on the
+ websocket room for `queue_id`.
+5. **Frontend update** — any connected client subscribed to that queue
+ applies the update to its Redux store, so UI fields, LoRAs, control
+ layers, IP adapters, and reference images all populate immediately.
+
+## Endpoint
+
+**Base URL:** `http://localhost:9090/api/v1/recall/{queue_id}`
+
+The queue id is usually `default`.
+
+### POST — Update Recall Parameters
+
+Updates recallable parameters for the given `queue_id`.
+
+```http
+POST /api/v1/recall/{queue_id}
+Content-Type: application/json
+
+{
+ "positive_prompt": "a beautiful landscape",
+ "negative_prompt": "blurry, low quality",
+ "model": "sd-1.5",
+ "steps": 20,
+ "cfg_scale": 7.5,
+ "width": 512,
+ "height": 512,
+ "seed": 12345
+}
+```
+
+All parameters are optional — only send the fields you want to update.
+
+#### Query parameters
+
+The POST endpoint accepts two optional boolean query parameters that control
+how reference images are merged into the frontend state:
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `strict` | `false` | When `true`, parameters **not** included in the request body are reset to their defaults (cleared on the frontend). When `false`, only the parameters you send are updated and everything else is left as-is. |
+| `append` | `false` | When `true`, recalled reference images (`ip_adapters` and `reference_images`) are **appended** to the frontend's existing reference-image list instead of replacing it. When `false` (or omitted), the recalled reference images **replace** the existing list. |
+
+`strict` and `append` are mutually exclusive — `strict` clears omitted
+parameters while `append` preserves and extends the existing list, so the two
+cannot be combined. Sending `?strict=true&append=true` returns
+**400 Bad Request**:
+
+```json
+{
+ "detail": "The 'strict' and 'append' query parameters are mutually exclusive"
+}
+```
+
+`append` only affects the reference-image collections (`ip_adapters` and
+`reference_images`). All other parameters (prompts, model, LoRAs, control
+layers, etc.) are updated the same way regardless of the flag.
+
+### GET — Retrieve Recall Parameters
+
+```http
+GET /api/v1/recall/{queue_id}
+```
+
+```json
+{
+ "status": "success",
+ "queue_id": "queue_123",
+ "note": "Use the frontend to access stored recall parameters, or set specific parameters using POST"
+}
+```
+
+## Request Schema
+
+### Core parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `positive_prompt` | string | Positive prompt text |
+| `negative_prompt` | string | Negative prompt text |
+| `model` | string | Main model name/identifier |
+| `refiner_model` | string | Refiner model name/identifier |
+| `vae_model` | string | VAE model name/identifier |
+| `scheduler` | string | Scheduler name |
+| `steps` | integer | Number of generation steps (≥1) |
+| `refiner_steps` | integer | Number of refiner steps (≥0) |
+| `cfg_scale` | number | CFG scale for guidance |
+| `cfg_rescale_multiplier` | number | CFG rescale multiplier |
+| `refiner_cfg_scale` | number | Refiner CFG scale |
+| `guidance` | number | Guidance scale |
+| `width` | integer | Image width in pixels (≥64) |
+| `height` | integer | Image height in pixels (≥64) |
+| `seed` | integer | Random seed (≥0) |
+| `denoise_strength` | number | Denoising strength (0–1) |
+| `refiner_denoise_start` | number | Refiner denoising start (0–1) |
+| `clip_skip` | integer | CLIP skip layers (≥0) |
+| `seamless_x` | boolean | Enable seamless X tiling |
+| `seamless_y` | boolean | Enable seamless Y tiling |
+| `refiner_positive_aesthetic_score` | number | Refiner positive aesthetic score |
+| `refiner_negative_aesthetic_score` | number | Refiner negative aesthetic score |
+
+### Collection parameters
+
+```typescript
+{
+ // LoRAs
+ loras?: Array<{
+ model_name: string; // LoRA model name
+ weight?: number; // Default: 0.75, Range: -10 to 10
+ is_enabled?: boolean; // Default: true
+ }>;
+
+ // Control Layers (ControlNet, T2I Adapter, Control LoRA)
+ control_layers?: Array<{
+ model_name: string; // Control adapter model name
+ image_name?: string; // Optional image filename from outputs/images
+ weight?: number; // Default: 1.0, Range: -1 to 2
+ begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
+ end_step_percent?: number; // Default: 1.0, Range: 0 to 1
+ control_mode?: "balanced" | "more_prompt" | "more_control"; // ControlNet only
+ }>;
+
+ // IP Adapters (includes FLUX Redux)
+ ip_adapters?: Array<{
+ model_name: string; // IP Adapter / FLUX Redux model name
+ image_name?: string; // Optional reference image filename from outputs/images
+ weight?: number; // Default: 1.0, Range: -1 to 2
+ begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
+ end_step_percent?: number; // Default: 1.0, Range: 0 to 1
+ method?: "full" | "style" | "composition"; // Default: "full"
+ image_influence?: "lowest" | "low" | "medium" | "high" | "highest"; // FLUX Redux only
+ }>;
+
+ // Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
+ reference_images?: Array<{
+ image_name: string; // Reference image filename from outputs/images
+ }>;
+}
+```
+
+## Model Name Resolution
+
+The backend resolves model names to their internal keys:
+
+1. **Main models** — resolved from the name to the model key.
+2. **LoRAs** — searched in the LoRA model database.
+3. **Control adapters** — tried in order: ControlNet → T2I Adapter → Control LoRA.
+4. **IP Adapters** — searched in the IP Adapter database; falls back to FLUX Redux.
+
+Models that cannot be resolved are skipped with a warning in the logs —
+the rest of the parameters are still applied.
+
+## Image File Handling
+
+When an `image_name` is supplied, the backend:
+
+1. Resolves `{INVOKEAI_ROOT}/outputs/images/{image_name}` via the image
+ files service (which also validates the path).
+2. Opens the image to extract width/height.
+3. Includes the image metadata in the event sent to the frontend.
+4. Logs whether the image was found.
+
+Images must be referenced by their filename as it appears in the
+outputs/images directory:
+
+- ✅ `"image_name": "example.png"`
+- ✅ `"image_name": "my_control_image_20240110.jpg"`
+- ❌ `"image_name": "outputs/images/example.png"` (no prefix)
+- ❌ `"image_name": "/full/path/to/example.png"` (no absolute paths)
+
+Missing images are logged as warnings but **do not** fail the request —
+remaining parameters are still applied.
+
+## Feature Details
+
+### LoRAs
+
+- Existing LoRAs are cleared before new ones are added.
+- Each LoRA's model config is fetched and applied with the specified weight.
+- LoRAs appear in the LoRA selector panel.
+
+### Control Layers
+
+- Fully supported with optional images from `outputs/images`.
+- Configuration includes model, weights, step percentages, control mode,
+ and an image reference.
+- Image availability is logged in the frontend console.
+
+### IP Adapters / FLUX Redux
+
+- Reference images loaded from `outputs/images` are validated and passed
+ through.
+- Configuration includes model, weights, step percentages, method, and an
+ image reference.
+- FLUX Redux uses `image_influence` instead of a numeric weight.
+
+### Model-free reference images
+
+Used by architectures that consume a reference image directly, with no
+separate adapter model:
+
+- **FLUX.2 Klein** — built-in reference image support.
+- **FLUX Kontext** — reference image associated with the main model.
+- **Qwen Image Edit** — reference image associated with the main model.
+
+Because there is no adapter model to resolve, these entries carry only
+`image_name`. When the frontend receives them, it picks the appropriate
+config flavor (`flux2_reference_image`, `flux_kontext_reference_image`,
+or `qwen_image_reference_image`) based on the currently-selected main
+model, matching the behavior of a manual drag-and-drop.
+
+## Usage Examples
+
+### cURL
+
+```bash
+# Core parameters
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "positive_prompt": "a cyberpunk city at night",
+ "negative_prompt": "dark, unclear",
+ "model": "sd-1.5",
+ "steps": 30
+ }'
+
+# Just the seed
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{"seed": 99999}'
+```
+
+### LoRAs only
+
+```bash
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "loras": [
+ {"model_name": "add-detail-xl", "weight": 0.8, "is_enabled": true},
+ {"model_name": "sd_xl_offset_example-lora_1.0", "weight": 0.5}
+ ]
+ }'
+```
+
+### Control layers with an image
+
+```bash
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "control_layers": [
+ {
+ "model_name": "controlnet-canny-sdxl-1.0",
+ "image_name": "my_control_image.png",
+ "weight": 0.75,
+ "begin_step_percent": 0.0,
+ "end_step_percent": 0.8,
+ "control_mode": "balanced"
+ }
+ ]
+ }'
+```
+
+### IP adapters with a reference image
+
+```bash
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "ip_adapters": [
+ {
+ "model_name": "ip-adapter-plus-face_sd15",
+ "image_name": "reference_face.png",
+ "weight": 0.7,
+ "method": "composition"
+ }
+ ]
+ }'
+```
+
+### Appending reference images (append mode)
+
+By default, recalled reference images **replace** whatever the frontend
+already has. Pass `?append=true` to **add** the recalled `ip_adapters` and
+`reference_images` to the existing list instead:
+
+```bash
+# Add a reference image without clearing the ones already on the frontend
+curl -X POST 'http://localhost:9090/api/v1/recall/default?append=true' \
+ -H "Content-Type: application/json" \
+ -d '{
+ "reference_images": [
+ {"image_name": "extra_reference.png"}
+ ]
+ }'
+```
+
+Combining `append=true` with `strict=true` is invalid and returns
+**400 Bad Request** (see [Query parameters](#query-parameters)).
+
+### Model-free reference images (FLUX.2 Klein / FLUX Kontext / Qwen Image Edit)
+
+```bash
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "FLUX.2 Klein",
+ "reference_images": [
+ {"image_name": "style_reference.png"}
+ ]
+ }'
+```
+
+### Complete configuration
+
+```bash
+curl -X POST http://localhost:9090/api/v1/recall/default \
+ -H "Content-Type: application/json" \
+ -d '{
+ "positive_prompt": "masterpiece, detailed photo with specific style",
+ "negative_prompt": "blurry, low quality",
+ "model": "FLUX Schnell",
+ "steps": 25,
+ "cfg_scale": 8.0,
+ "width": 1024,
+ "height": 768,
+ "seed": 42,
+ "loras": [
+ {"model_name": "add-detail-xl", "weight": 0.6}
+ ],
+ "control_layers": [
+ {
+ "model_name": "controlnet-depth-sdxl-1.0",
+ "image_name": "depth_map.png",
+ "weight": 1.0,
+ "end_step_percent": 0.7
+ }
+ ],
+ "ip_adapters": [
+ {
+ "model_name": "ip-adapter-plus-face_sd15",
+ "image_name": "style_reference.png",
+ "weight": 0.5,
+ "method": "style"
+ }
+ ]
+ }'
+```
+
+### Python
+
+```python
+import requests
+
+API_URL = "http://localhost:9090/api/v1/recall/default"
+
+params = {
+ "positive_prompt": "a serene forest",
+ "negative_prompt": "people, buildings",
+ "steps": 25,
+ "cfg_scale": 7.0,
+ "seed": 42,
+}
+
+response = requests.post(API_URL, json=params)
+result = response.json()
+print(f"Status: {result['status']}")
+print(f"Updated {result['updated_count']} parameters")
+```
+
+### JavaScript
+
+```javascript
+const API_URL = 'http://localhost:9090/api/v1/recall/default';
+
+fetch(API_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ positive_prompt: 'a beautiful sunset',
+ steps: 20,
+ width: 768,
+ height: 768,
+ seed: 12345,
+ }),
+})
+ .then((res) => res.json())
+ .then((data) => console.log(data));
+```
+
+## Response Format
+
+```json
+{
+ "status": "success",
+ "queue_id": "default",
+ "updated_count": 15,
+ "parameters": {
+ "positive_prompt": "...",
+ "steps": 25,
+ "loras": [
+ {"model_key": "abc123...", "weight": 0.6, "is_enabled": true}
+ ],
+ "control_layers": [
+ {
+ "model_key": "controlnet-xyz...",
+ "weight": 1.0,
+ "image": {"image_name": "depth_map.png", "width": 1024, "height": 768}
+ }
+ ],
+ "ip_adapters": [
+ {
+ "model_key": "ip-adapter-xyz...",
+ "weight": 0.5,
+ "image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}
+ }
+ ],
+ "reference_images": [
+ {"image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}}
+ ]
+ }
+}
+```
+
+## WebSocket Events
+
+Parameter updates emit a `recall_parameters_updated` event to the queue
+room. Connected frontend clients automatically:
+
+1. Apply standard parameters (prompts, steps, dimensions, etc.).
+2. Load and add LoRAs to the LoRA list.
+3. Apply control-layer configurations.
+4. Merge the recalled reference images — IP Adapter / FLUX Redux entries and
+ model-free reference images both feed the same reference-image list, using
+ the config flavor that matches the currently-selected main model. By
+ default this **replaces** the existing list; with `append=true` it is
+ **added** to whatever is already there (see
+ [Query parameters](#query-parameters)).
+
+## Error Handling
+
+- **400 Bad Request** — invalid parameters or parameter values, or the
+ mutually exclusive `strict=true&append=true` combination (see
+ [Query parameters](#query-parameters)).
+- **500 Internal Server Error** — server-side storage or retrieval failure.
+
+Errors include detailed messages. Missing images and unresolved model
+names are **not** errors — they are logged and the remaining parameters
+are still applied.
+
+## Logging
+
+### Backend
+
+```
+INFO: Resolved ControlNet model name 'controlnet-canny-sdxl-1.0' to key 'controlnet-xyz...'
+INFO: Found image file: depth_map.png (1024x768)
+INFO: Updated 12 recall parameters for queue default
+INFO: Resolved 1 LoRA(s)
+INFO: Resolved 1 control layer(s)
+INFO: Resolved 1 IP adapter(s)
+INFO: Resolved 1 reference image(s)
+```
+
+### Frontend
+
+Set `localStorage.ROARR_FILTER = 'debug'` in the browser to see all debug
+messages under the `events` namespace.
+
+```
+INFO: Applied 5 recall parameters to store
+INFO: Applied 2 reference image(s) (IP adapters + model-free), replacing existing list
+DEBUG: Built IP adapter ref image state: ip-adapter-xyz... (weight: 0.7)
+DEBUG: IP adapter image: outputs/images/depth_map.png (1024x768)
+```
+
+## Implementation Details
+
+- Parameters are stored in the client state persistence service under
+ `recall_*` keys, scoped to the `queue_id`.
+- Numeric validation runs at the FastAPI layer (e.g. `steps ≥ 1`, `width ≥ 64`).
+- Only non-null parameters are processed, stored, and broadcast.
+- Model-key resolution runs **after** the raw parameters are stored, so
+ an unresolvable model name simply drops out of the broadcast but does
+ not corrupt the persisted state.
+- The broadcast payload contains resolved model keys and image metadata
+ (width/height) so the frontend can populate its store without extra
+ round-trips.
+
+## Troubleshooting
+
+### Image not found
+
+If you see "Image file not found" in the logs:
+
+1. Verify the filename matches exactly (case-sensitive).
+2. Ensure the image is in `{INVOKEAI_ROOT}/outputs/images/`.
+3. Check that the filename does not include the `outputs/images/` prefix.
+
+### Model not found
+
+If you see "Could not find model":
+
+1. Verify the model name matches exactly (case-sensitive).
+2. Ensure the model is installed.
+3. Check the name via the Models Manager panel.
+
+### Event not received
+
+1. Check the browser console for socket connection errors.
+2. Verify the `queue_id` matches the frontend's queue (usually `default`).
+3. Check backend logs for event emission errors.
+
+## Limitations
+
+- **Model availability** — models referenced in the payload must be installed.
+- **Image availability** — images must exist in `outputs/images`; remote
+ URLs are not supported.
+- **Canvas auto-layer creation** — control layers and IP adapters with
+ images populate the recall state, but creating a canvas layer from
+ them still happens through the UI.
+
+## Future enhancements
+
+Potential improvements not yet implemented:
+
+1. Auto-create canvas layers from control-layer images in the payload.
+2. Auto-create reference-image layers from IP Adapter images in the payload.
+3. Support remote image URLs in addition to local `outputs/images` filenames.
+4. Image upload capability (accept base64 or file upload directly via the API).
+5. Batch operations that target multiple `queue_id`s in a single request.
diff --git a/docs/src/content/docs/development/Guides/tests.mdx b/docs/src/content/docs/development/Guides/tests.mdx
new file mode 100644
index 00000000000..c2dfd52b98c
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/tests.mdx
@@ -0,0 +1,102 @@
+---
+title: Writing Tests
+lastUpdated: 2026-02-20
+---
+
+## Frontend Tests
+
+We use `vitest` to run the frontend tests. (See [vite.config.ts](https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/frontend/web/vite.config.mts) for the default `vitest` options.)
+
+{/* TODO: Finish frontend tests docs */}
+
+## Backend Tests
+
+We use `pytest` to run the backend python tests. (See [pyproject.toml](https://github.com/invoke-ai/InvokeAI/blob/main/pyproject.toml) for the default `pytest` options.)
+
+### Fast vs. Slow
+All tests are categorized as either 'fast' (no test annotation) or 'slow' (annotated with the `@pytest.mark.slow` decorator).
+
+'Fast' tests are run to validate every PR, and are fast enough that they can be run routinely during development.
+
+'Slow' tests are currently only run manually on an ad-hoc basis. In the future, they may be automated to run nightly. Most developers are only expected to run the 'slow' tests that directly relate to the feature(s) that they are working on.
+
+As a rule of thumb, tests should be marked as 'slow' if there is a chance that they take >1s (e.g. on a CPU-only machine with slow internet connection). Common examples of slow tests are tests that depend on downloading a model, or running model inference.
+
+### Running Tests
+
+Below are some common test commands:
+
+```bash
+# Run the fast tests. (This implicitly uses the configured default option: `-m "not slow"`.)
+pytest tests/
+
+# Equivalent command to run the fast tests.
+pytest tests/ -m "not slow"
+
+# Run the slow tests.
+pytest tests/ -m "slow"
+
+# Run the slow tests from a specific file.
+pytest tests/path/to/slow_test.py -m "slow"
+
+# Run all tests (fast and slow).
+pytest tests -m ""
+```
+
+### Test Organization
+
+All backend tests are in the [`tests/`](https://github.com/invoke-ai/InvokeAI/tree/main/tests) directory. This directory mirrors the organization of the `invokeai/` directory. For example, tests for `invokeai/model_management/model_manager.py` would be found in `tests/model_management/test_model_manager.py`.
+
+TODO: The above statement is aspirational. A re-organization of legacy tests is required to make it true.
+
+### Tests that depend on models
+
+There are a few things to keep in mind when adding tests that depend on models.
+
+1. If a required model is not already present, it should automatically be downloaded as part of the test setup.
+2. If a model is already downloaded, it should not be re-downloaded unnecessarily.
+3. Take reasonable care to keep the total number of models required for the tests low. Whenever possible, re-use models that are already required for other tests. If you are adding a new model, consider including a comment to explain why it is required/unique.
+
+There are several utilities to help with model setup for tests. Here is a sample test that depends on a model:
+
+```python
+import pytest
+import torch
+
+from invokeai.backend.model_management.models.base import BaseModelType, ModelType
+from invokeai.backend.util.test_utils import install_and_load_model
+
+@pytest.mark.slow
+def test_model(model_installer, torch_device):
+ model_info = install_and_load_model(
+ model_installer=model_installer,
+ model_path_id_or_url="HF/dummy_model_id",
+ model_name="dummy_model",
+ base_model=BaseModelType.StableDiffusion1,
+ model_type=ModelType.Dummy,
+ )
+
+ dummy_input = build_dummy_input(torch_device)
+
+ with torch.no_grad(), model_info as model:
+ model.to(torch_device, dtype=torch.float32)
+ output = model(dummy_input)
+
+ # Validate output...
+```
+
+### Test Coverage
+
+To review test coverage, append `--cov` to your pytest command:
+
+```bash
+pytest tests/ --cov
+```
+
+Test outcomes and coverage will be reported in the terminal. In addition, a more detailed report is created in both XML and HTML format in the `./coverage` folder. The HTML output is particularly helpful in identifying untested statements where coverage should be improved. The HTML report can be viewed by opening `./coverage/html/index.html`.
+
+:::note HTML coverage report output example
+ 
+
+ 
+:::
diff --git a/docs/src/content/docs/development/Guides/workflow-api.mdx b/docs/src/content/docs/development/Guides/workflow-api.mdx
new file mode 100644
index 00000000000..effe66a352a
--- /dev/null
+++ b/docs/src/content/docs/development/Guides/workflow-api.mdx
@@ -0,0 +1,106 @@
+---
+title: Workflow Execution API
+---
+
+## Overview
+
+InvokeAI's HTTP API can be used programmatically from external clients, but one distinction is easy to miss:
+
+- A saved **workflow** is not executed directly.
+- The queue accepts an executable **graph**.
+- If you fetch a saved workflow from `/api/v1/workflows/`, you still need to enqueue a graph with `POST /api/v1/queue/default/enqueue_batch`.
+
+This is a common source of confusion when a client can successfully read workflow records but cannot execute them.
+
+If you are using multi-user mode, include your `Authorization: Bearer ` header on these requests.
+
+## Minimal `enqueue_batch` Example
+
+The smallest useful "hello world" is a simple graph with one math node. This avoids any image-model setup and lets you verify that your client can enqueue work and poll for completion.
+
+```python
+import requests
+import time
+
+BASE_URL = "http://localhost:9090"
+
+graph = {
+ "id": "hello-graph",
+ "nodes": {
+ "add-node": {
+ "id": "add-node",
+ "type": "add",
+ "a": 2,
+ "b": 3,
+ "is_intermediate": False,
+ "use_cache": True,
+ }
+ },
+ "edges": [],
+}
+
+payload = {
+ "batch": {
+ "graph": graph,
+ "runs": 1,
+ "origin": "external-client",
+ }
+}
+
+enqueue_response = requests.post(
+ f"{BASE_URL}/api/v1/queue/default/enqueue_batch",
+ json=payload,
+)
+enqueue_response.raise_for_status()
+
+item_id = enqueue_response.json()["item_ids"][0]
+
+while True:
+ item_response = requests.get(f"{BASE_URL}/api/v1/queue/default/i/{item_id}")
+ item_response.raise_for_status()
+ item = item_response.json()
+
+ status = item["status"]
+ if status == "completed":
+ print(item["session"]["results"])
+ break
+ if status == "failed":
+ raise RuntimeError(item["error_message"])
+ if status == "canceled":
+ raise RuntimeError("Queue item was canceled")
+
+ time.sleep(0.5)
+```
+
+For the graph above, the completed queue item will contain the output in `session.results`.
+
+## Getting an Image Output
+
+For image-generation graphs, the completed queue item also includes `session.results`, but the output object will typically contain an image reference instead of a plain integer value.
+
+For example, an image output may look like this:
+
+```json
+{
+ "type": "image_output",
+ "image": {
+ "image_name": "abc123.png"
+ }
+}
+```
+
+Once you have the `image_name`, you can download the generated file from:
+
+```text
+GET /api/v1/images/i/{image_name}/full
+```
+
+If you only need the file preview, you can also use:
+
+```text
+GET /api/v1/images/i/{image_name}/thumbnail
+```
+
+## Polling vs. WebSockets
+
+The example above uses polling because it is the easiest way to get started from an external client. If you need lower-latency updates, you can also use the socket events emitted during queue execution.
diff --git a/docs/src/content/docs/development/Process/pr-merge-policy.mdx b/docs/src/content/docs/development/Process/pr-merge-policy.mdx
new file mode 100644
index 00000000000..ebd08feaaf0
--- /dev/null
+++ b/docs/src/content/docs/development/Process/pr-merge-policy.mdx
@@ -0,0 +1,72 @@
+---
+title: PR Merge Policy
+lastUpdated: 2026-02-19
+---
+
+import { Steps } from '@astrojs/starlight/components';
+
+This document outlines the process for reviewing and merging pull requests (PRs) into the InvokeAI repository.
+
+## Review Process
+
+
+ 1. Assignment
+
+ One of the repository maintainers will assign collaborators to review a pull request. The assigned reviewer(s) will be responsible for conducting the code review.
+
+ 2. Review and Iteration
+
+ The assignee is responsible for:
+ - Reviewing the PR thoroughly
+ - Providing constructive feedback
+ - Iterating with the PR author until the assignee is satisfied that the PR is fit to merge
+ - Ensuring the PR meets code quality standards, follows project conventions, and doesn't introduce bugs or regressions
+
+ 3. Approval and Notification
+
+ Once the assignee is satisfied with the PR:
+ - The assignee approves the PR
+ - The assignee alerts one of the maintainers that the PR is ready for merge using the **#request-reviews Discord channel**
+
+ 4. Final Merge
+
+ One of the maintainers is responsible for:
+ - Performing a final check of the PR
+ - Merging the PR into the appropriate branch
+
+ :::caution[Important]
+ Collaborators are strongly discouraged from merging PRs on their own, except in case of emergency (e.g., critical bug fix and no maintainer is available).
+ :::
+
+ 5. Release Policy
+
+ Once a feature release candidate is published, no feature PRs are to
+ be merged into main. Only bugfixes are allowed until the final
+ release.
+
+
+## Best Practices
+
+### Clean Commit History
+
+To encourage a clean development log, PR authors are encouraged to use `git rebase -i` to suppress trivial commit messages (e.g., `ruff` and `prettier` formatting fixes) after the PR is accepted but before it is merged.
+
+### Merge Strategy
+
+The maintainer will perform either a **3-way merge** or **squash merge** when merging a PR into the `main` branch. This approach helps avoid rebase conflict hell and maintains a cleaner project history.
+
+### Attribution
+
+The PR author should reference any papers, source code or
+documentation that they used while creating the code both in the PR
+and as comments in the code itself. If there are any licensing
+restrictions, these should be linked to and/or reproduced in the repo
+root.
+
+## Summary
+
+This policy ensures that:
+- All PRs receive proper review from assigned collaborators
+- Maintainers have final oversight before code enters the main branch
+- The commit history remains clean and meaningful
+- Merge conflicts are minimized through appropriate merge strategies
diff --git a/docs/src/content/docs/development/Process/release-process.mdx b/docs/src/content/docs/development/Process/release-process.mdx
new file mode 100644
index 00000000000..9869e4940e2
--- /dev/null
+++ b/docs/src/content/docs/development/Process/release-process.mdx
@@ -0,0 +1,157 @@
+---
+title: Release Process
+lastUpdated: 2025-12-26
+---
+
+The Invoke application is published as a python package on [PyPI]. This includes both a source distribution and built distribution (a wheel).
+
+Most users install it with the [Launcher](https://github.com/invoke-ai/launcher/), others with `pip`.
+
+The launcher uses GitHub as the source of truth for available releases.
+
+## Broad Strokes
+
+- Merge all changes and bump the version in the codebase.
+- Tag the release commit.
+- Wait for the release workflow to complete.
+- Approve the PyPI publish jobs.
+- Write GH release notes.
+
+## General Prep
+
+Make a developer call-out for PRs to merge. Merge and test things
+out. Create a branch with a name like user/chore/vX.X.X-prep and bump the version by editing
+`invokeai/version/invokeai_version.py` and commit locally.
+
+## Release Workflow
+
+The `release.yml` workflow runs a number of jobs to handle code checks, tests, build and publish on PyPI.
+
+It is triggered on **tag push**, when the tag matches `v*`.
+
+### Triggering the Workflow
+
+Ensure all commits that should be in the release are merged into this branch, and that you have pulled them locally.
+
+Run `make tag-release` to tag the current commit and kick off the workflow. You will be prompted to provide a message - use the version specifier.
+
+If this version's tag already exists for some reason (maybe you had to make a last minute change), the script will overwrite it.
+
+Push the commit to trigger the workflow.
+
+> In case you cannot use the Make target, the release may also be dispatched [manually] via GH.
+
+### Workflow Jobs and Process
+
+The workflow consists of a number of concurrently-run checks and tests, then two final publish jobs.
+
+The publish jobs require manual approval and are only run if the other jobs succeed.
+
+#### `check-version` Job
+
+This job ensures that the `invokeai` python package version specifier matches the tag for the release. The version specifier is pulled from the `__version__` variable in `invokeai/version/invokeai_version.py`.
+
+This job uses [samuelcolvin/check-python-version].
+
+> Any valid [version specifier] works, so long as the tag matches the version. The release workflow works exactly the same for `RC`, `post`, `dev`, etc.
+
+#### Check and Test Jobs
+
+Next, these jobs run and must pass. They are the same jobs that are run for every PR.
+
+- **`python-tests`**: runs `pytest` on matrix of platforms
+- **`python-checks`**: runs `ruff` (format and lint)
+- **`frontend-tests`**: runs `vitest`
+- **`frontend-checks`**: runs `prettier` (format), `eslint` (lint), `dpdm` (circular refs), `tsc` (static type check) and `knip` (unused imports)
+- **`typegen-checks`**: ensures the frontend and backend types are synced
+
+#### `build-wheel` Job
+
+This sets up both python and frontend dependencies and builds the python package. Internally, this runs `./scripts/build_wheel.sh` and uploads `dist.zip`, which contains the wheel and unarchived build.
+
+You don't need to download or test these artifacts.
+
+#### Sanity Check & Smoke Test
+
+At this point, the release workflow pauses as the remaining publish jobs require approval.
+
+It's possible to test the python package before it gets published to PyPI. We've never had problems with it, so it's not necessary to do this.
+
+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](/start-here/manual/) - but instead of installing from PyPI, install from the wheel
+- Test the app
+
+##### Something isn't right
+
+If testing reveals any issues, no worries. Cancel the workflow, which will cancel the pending publish jobs (you didn't approve them prematurely, right?) and start over.
+
+#### PyPI Publish Jobs
+
+The publish jobs will not run if any of the previous jobs fail.
+
+They use [GitHub environments], which are configured as [trusted publishers] on PyPI.
+
+Both jobs require a @lstein or @blessedcoolant to approve them from the workflow's **Summary** tab.
+
+- Click the **Review deployments** button
+- Select the environment (either `testpypi` or `pypi` - typically you select both)
+- Click **Approve and deploy**
+
+> **If the version already exists on PyPI, the publish jobs will fail.** PyPI only allows a given version to be published once - you cannot change it. If version published on PyPI has a problem, you'll need to "fail forward" by bumping the app version and publishing a followup release.
+
+##### Failing PyPI Publish
+
+Check the [python infrastructure status page] for incidents.
+
+If there are no incidents, contact @lstein or @blessedcoolant, who have owner access to GH and PyPI, to see if access has expired or something like that.
+
+#### `publish-testpypi` Job
+
+Publishes the distribution on the [Test PyPI] index, using the `testpypi` GitHub environment.
+
+This job is not required for the production PyPI publish, but included just in case you want to test the PyPI release for some reason:
+
+- Approve this publish job without approving the prod publish
+- Let it finish
+- 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
+
+Publishes the distribution on the production PyPI index, using the `pypi` GitHub environment.
+
+It's a good idea to wait to approve and run this job until you have the release notes ready!
+
+## Prep and publish the GitHub Release
+
+1. [Draft a new release] on GitHub, choosing the tag that triggered the release.
+2. The **Generate release notes** button automatically inserts the changelog and new contributors. Make sure to select the correct tags for this release and the last stable release. GH often selects the wrong tags - do this manually.
+3. Write the release notes, describing important changes. Contributions from community members should be shouted out. Use the GH-generated changelog to see all contributors. If there are Weblate translation updates, open that PR and shout out every person who contributed a translation.
+4. Check **Set as a pre-release** if it's a pre-release.
+5. Approve and wait for the `publish-pypi` job to finish if you haven't already.
+6. Publish the GH release.
+7. Post the release in Discord in the [releases](https://discord.com/channels/1020123559063990373/1149260708098359327) channel with abbreviated notes. For example:
+ > Invoke v5.7.0 (stable): [https://github.com/invoke-ai/InvokeAI/releases/tag/v5.7.0](https://github.com/invoke-ai/InvokeAI/releases/tag/v5.7.0)
+ >
+ > It's a pretty big one - Form Builder, Metadata Nodes (thanks @SkunkWorxDark!), and much more.
+8. Right click the message in releases and copy the link to it. Then, post that link in the [new-release-discussion](https://discord.com/channels/1020123559063990373/1149506274971631688) channel. For example:
+ > Invoke v5.7.0 (stable): [https://discord.com/channels/1020123559063990373/1149260708098359327/1344521744916021248](https://discord.com/channels/1020123559063990373/1149260708098359327/1344521744916021248)
+
+## Manual Release
+
+The `release` workflow can be dispatched manually. You must dispatch the workflow from the right tag, else it will fail the version check.
+
+This functionality is available as a fallback in case something goes wonky. Typically, releases should be triggered via tag push as described above.
+
+[PyPI]: https://pypi.org/
+[Draft a new release]: https://github.com/invoke-ai/InvokeAI/releases/new
+[Test PyPI]: https://test.pypi.org/
+[version specifier]: https://packaging.python.org/en/latest/specifications/version-specifiers/
+[GitHub environments]: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
+[trusted publishers]: https://docs.pypi.org/trusted-publishers/
+[samuelcolvin/check-python-version]: https://github.com/samuelcolvin/check-python-version
+[manually]: #manual-release
+[python infrastructure status page]: https://status.python.org/
diff --git a/docs/src/content/docs/development/Setup/dev-environment.mdx b/docs/src/content/docs/development/Setup/dev-environment.mdx
new file mode 100644
index 00000000000..a92223e3ebb
--- /dev/null
+++ b/docs/src/content/docs/development/Setup/dev-environment.mdx
@@ -0,0 +1,275 @@
+---
+title: Development Environment
+lastUpdated: 2026-02-19
+---
+
+import { LinkCard, Steps, Tabs, TabItem, FileTree, LinkButton } from '@astrojs/starlight/components'
+import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
+
+:::caution
+ Invoke uses a SQLite database. When you run the application as a dev install, you accept responsibility for your database. This means making regular backups (especially before pulling) and/or fixing it yourself in the event that a PR introduces a schema change.
+
+ If you don't need to persist your db, you can use an ephemeral in-memory database by setting `use_memory_db: true` in your `invokeai.yaml` file. You'll also want to set `scan_models_on_startup: true` so that your models are registered on startup.
+:::
+
+## Initial Setup
+
+
+ 1. Refer to the system requirements.
+
+
+
+ 2. Fork and clone the InvokeAI git repository.
+
+
+ Fork Repository
+
+
+ Next, clone your fork to your local machine. You can use either HTTPS or SSH, depending on your git configuration.
+
+ 3. This repository uses Git LFS to manage large files. To ensure all assets are downloaded:
+ - Install git-lfs → [Download here](https://git-lfs.com/)
+ - Enable automatic LFS fetching for this repository:
+ ```shell
+ git config lfs.fetchinclude "*"
+ ```
+ - Fetch files from LFS (only needs to be done once; subsequent `git pull` will fetch changes automatically):
+ ```shell
+ 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](/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.
+ - Use `uv sync` instead of `uv pip install` so the environment is synchronized from the repository lockfile.
+ - The current project is installed as an editable install by default. That means your changes to the python code will be reflected when you restart the Invoke server.
+ - Add the `dev`, `test`, `docs`, and appropriate GPU package options with `--extra`. You may or may not need the `xformers` option - follow the manual install guide to figure that out.
+
+ With the modifications made, the sync command should look something like this:
+
+ ```sh
+ uv sync --frozen \
+ --python 3.12 \
+ --managed-python \
+ --extra dev \
+ --extra test \
+ --extra docs \
+ --extra cuda \
+ --extra xformers
+ ```
+ 6. At this point, you should have Invoke installed, a venv set up and activated, and the server running. But you will see a warning in the terminal that no UI was found. If you go to the URL for the server, you won't get a UI.
+
+ This is because the UI build is not distributed with the source code. You need to build it manually. End the running server instance.
+
+ *(If you only want to edit the docs, you can stop here and skip to the **Documentation** section below.)*
+
+ 7. Install the frontend dev toolchain, paying attention to versions:
+
+ - [`nodejs`](https://nodejs.org/) (tested on LTS, v22)
+ - [`pnpm`](https://pnpm.io/installation) (tested on v10)
+
+ 8. Do a production build of the frontend:
+
+ ```sh
+ cd /invokeai/frontend/web
+ pnpm i
+ pnpm build
+ ```
+
+ 9. Restart the server and navigate to the URL. You should get a UI. After making changes to the python code, restart the server to see those changes.
+
+
+## Backend Development
+
+Experimenting with changes to the Python source code is a drag if you have to re-start the server and re-load multi-gigabyte models after every change.
+
+For a faster development workflow, add the `--dev_reload` flag when starting the server. The server will watch for changes to all the Python files in the `invokeai` directory and apply those changes to the running server on the fly.
+
+This will allow you to avoid restarting the server (and reloading models) in most cases, but there are some caveats; see the [jurigged documentation](https://github.com/breuleux/jurigged#caveats) for details.
+
+### Testing
+
+The backend tests require the `test` dependency group, which you installed during the initial setup.
+
+See the [Tests](../tests) documentation for information about running and writing tests.
+
+## Frontend Development
+
+You'll need to run `pnpm build` every time you pull in new changes to the frontend.
+
+Another option is to skip the build and instead run the UI in dev mode:
+
+```sh
+cd invokeai/frontend/web
+pnpm dev
+```
+
+This starts a vite dev server for the UI at `127.0.0.1:5173`, which you will use instead of `127.0.0.1:9090`.
+
+The dev mode is substantially slower than the production build but may be more convenient if you just need to test things out. It will hot-reload the UI as you make changes to the frontend code. Sometimes the hot-reload doesn't work, and you need to manually refresh the browser tab.
+
+## Documentation
+
+This documentation is built on [Astro Starlight](https://starlight.astro.build/). It provides a pleasant developer environment for writing engaging documentation, and is built on top of the Astro static site generator, which provides a powerful and flexible framework for building fast, modern websites.
+
+To contribute to the documentation, simply edit the markdown files in the `./docs` directory. You can run a local dev server with hot-reloading for changes made to the docs.
+
+
+ - **docs**
+ - public/
+ - src
+ - content docs content lives here
+ - docs
+ - lib
+ - components/
+ - utils/
+ - content.config.ts
+ - scripts/
+ - tests/
+ - invokeai/
+ - docker/
+ - coverage/
+
+
+
+ 1. Navigate to the `docs` directory and install the dependencies:
+
+ ```sh
+ cd docs
+ pnpm install
+ ```
+ 2. Start the dev server:
+
+ ```sh
+ pnpm run dev
+ ```
+
+
+## VSCode Setup
+
+VSCode offers excellent tools for InvokeAI development, including a python debugger, automatic virtual environment activation, and remote development capabilities.
+
+### Prerequisites
+
+First, ensure you have the following extensions installed:
+- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python)
+- [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance)
+
+It's also highly recommended to install the Jupyter extensions if you plan on working with notebooks:
+- [Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter)
+- [Jupyter Cell Tags](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.vscode-jupyter-cell-tags)
+- [Jupyter Notebook Renderers](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter-renderers)
+- [Jupyter Slide Show](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.vscode-jupyter-slideshow)
+
+### Configuration
+
+
+
+ Creating a VSCode workspace for working on InvokeAI is highly recommended to hold InvokeAI-specific settings and configs.
+
+ 1. Open the InvokeAI repository directory in VSCode
+ 2. Go to `File` > `Save Workspace As` and save it *outside* the repository
+
+ **Default Python Interpreter**
+
+ To enable automatic virtual environment activation:
+ 1. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run `Preferences: Open Workspace Settings (JSON)`
+ 2. Add `python.defaultInterpreterPath` to your settings, pointing to your virtual environment's python executable:
+
+ ```jsonc
+ {
+ "folders": [
+ { "path": "InvokeAI" },
+ { "path": "/path/to/invokeai_root" }
+ ],
+ "settings": {
+ "python.defaultInterpreterPath": "/path/to/invokeai_root/.venv/bin/python"
+ }
+ }
+ ```
+ Now, opening the integrated terminal or running python will automatically use your InvokeAI virtual environment.
+
+
+
+ We use Python's typing system in InvokeAI. PR reviews will include checking that types are present and correct.
+
+ Pylance provides type checking in the editor. To enable it:
+
+ 1. Open a Python file
+ 2. Look along the status bar in VSCode for `{ } Python`
+ 3. Click the `{ }`
+ 4. Turn type checking on (Basic is fine)
+
+ You'll now see red squiggly lines where type issues are detected. Hover your cursor over the indicated symbols to see what's wrong.
+
+
+
+ Debugging configs are managed in a `launch.json` file. Follow the [official guide](https://code.visualstudio.com/docs/python/debugging) to set up your `launch.json` and try it out.
+
+ Add these InvokeAI debugging configurations to your `launch.json`:
+
+ ```jsonc
+ {
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "InvokeAI Web",
+ "type": "python",
+ "request": "launch",
+ "program": "scripts/invokeai-web.py",
+ "args": [
+ "--root", "/path/to/invokeai_root",
+ "--host", "0.0.0.0"
+ ],
+ "justMyCode": true
+ },
+ {
+ "name": "InvokeAI CLI",
+ "type": "python",
+ "request": "launch",
+ "program": "scripts/invokeai-cli.py",
+ "justMyCode": true
+ },
+ {
+ "name": "InvokeAI Test",
+ "type": "python",
+ "request": "launch",
+ "module": "pytest",
+ "args": ["--capture=no"],
+ "justMyCode": true
+ },
+ {
+ "name": "InvokeAI Single Test",
+ "type": "python",
+ "request": "launch",
+ "module": "pytest",
+ "args": ["tests/nodes/test_invoker.py"],
+ "justMyCode": true
+ }
+ ]
+ }
+ ```
+
+
+
+ This provides a smooth experience for running the backend on a powerful Linux machine while developing on another device.
+
+ Consult the [official guide](https://code.visualstudio.com/docs/remote/remote-overview) to get it set up. We suggest using VSCode's included settings sync so that your remote dev host has all the same app settings and extensions automatically.
+
+ :::tip[Port Forwarding]
+ Automatic port forwarding can be flaky. You can disable it in `Preferences: Open Remote Settings (ssh: hostname)` by unticking `remote.autoForwardPorts`.
+
+ To forward ports reliably, use SSH on the remote dev client:
+ ```bash
+ ssh -L 9090:localhost:9090 -L 5173:localhost:5173 user@remote-dev-host
+ ```
+ Run this outside the VSCode integrated terminal so it persists across VSCode restarts.
+ :::
+
+
diff --git a/docs/src/content/docs/development/index.mdx b/docs/src/content/docs/development/index.mdx
new file mode 100644
index 00000000000..77e362bc10d
--- /dev/null
+++ b/docs/src/content/docs/development/index.mdx
@@ -0,0 +1,48 @@
+---
+title: InvokeAI Development
+sidebar:
+ order: 1
+lastUpdated: 2026-02-19
+---
+
+import { Card, CardGrid, LinkButton } from '@astrojs/starlight/components';
+
+This section of the documentation is for developers interested in contributing to the InvokeAI codebase, or building on top of it. It includes guides for setting up your development environment, understanding the project structure, and making your first contribution.
+
+
+
+ Instructions for setting up your local development environment, including how to run the project locally and how to set up your tooling.
+
+
+ Learn more
+
+
+
+ An introduction to the front end codebase, including the technologies used and how to get started.
+
+
+ Learn more
+
+
+
+ A collection of guides for common development tasks, such as adding new model architectures, making tests, and more.
+
+
+ Learn more
+
+
+
+ An overview of the InvokeAI architecture, including the major components and how they interact.
+
+
+ Learn more
+
+
+
+ An overview of the development processes we follow, including our pull request merge policy and release process.
+
+
+ Learn more
+
+
+
diff --git a/docs/src/content/docs/features/Canvas/canvas-projects.mdx b/docs/src/content/docs/features/Canvas/canvas-projects.mdx
new file mode 100644
index 00000000000..92c549abe2b
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/canvas-projects.mdx
@@ -0,0 +1,60 @@
+---
+title: Canvas Projects
+sidebar:
+ badge: New
+ order: 7
+---
+
+import { Steps } from '@astrojs/starlight/components';
+
+Canvas Projects let you save the entire state of a canvas — including all layers, masks, reference images, generation parameters, and LoRAs — into a single `.invk` file that you can reopen later or share with someone else.
+
+`.invk` files are ZIP archives. When saved images can be fetched successfully from the server, they embed the actual image bytes for every layer and reference image, so a project is self-contained: opening it on another machine or after wiping the gallery can restore those images.
+
+## Saving a project
+
+
+1. Open the canvas and arrange your layers, masks, reference images, and parameters the way you want them.
+2. Open the archive menu in the canvas toolbar, or open the canvas context menu and choose **Project**.
+3. Choose **Save Canvas Project**.
+4. Optionally rename the project (the default is **Canvas Project**).
+5. Save the `.invk` file to disk.
+
+
+What gets saved:
+
+- All raster, inpaint, and control layers, with their image data, transforms, opacity, and lock state.
+- All masks.
+- Reference images.
+- Currently configured generation parameters (model, prompts, scheduler, seed, dimensions, etc.).
+- LoRAs and their weights.
+
+## Loading a project
+
+
+1. Open the archive menu in the canvas toolbar, or open the canvas context menu and choose **Project**.
+2. Choose **Load Canvas Project**.
+3. Pick the `.invk` file.
+
+
+When a project is loaded, the canvas is replaced with the project's state. LoRAs are reset first, then re-applied from the project, so opening a project never leaves stale LoRAs from your previous session attached.
+
+### Image deduplication
+
+Loading a project does **not** blindly re-upload every embedded image. Invoke compares each embedded image against what is already in your gallery and only uploads the images that are missing. Re-opening the same project a second time, or opening it shortly after saving it, is therefore very fast — most or all images will already be on the server.
+
+This also means a project shared with another user will upload its missing embedded images the first time it is opened on that user's machine, then become nearly free to re-open after that.
+
+To keep the gallery responsive during large imports, image fetches and uploads are limited to a small number of concurrent requests.
+
+## What `.invk` does *not* save
+
+A `.invk` file is a canvas state snapshot. It does **not** contain:
+
+- The models, LoRAs, or embeddings themselves — only references to them. If you share a project, the recipient needs the same models installed (or compatible substitutes).
+- Workflow editor state (use **Save Workflow** in the workflow editor for that).
+- Gallery boards or images outside the canvas.
+
+## Sharing projects
+
+`.invk` files are safe to share directly. The recipient loads the file from the canvas toolbar archive menu or canvas context menu. They'll need any referenced models / LoRAs installed locally; if a referenced model is missing, the parameter slot will be empty and they can pick a substitute before generating.
diff --git a/docs/src/content/docs/features/Canvas/gradient-tool.mdx b/docs/src/content/docs/features/Canvas/gradient-tool.mdx
new file mode 100644
index 00000000000..0c9dee0f929
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/gradient-tool.mdx
@@ -0,0 +1,75 @@
+---
+title: Gradient Tool
+description: Learn how to paint linear and radial gradients on canvas raster layers.
+lastUpdated: 2026-05-16
+sidebar:
+ order: 4
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+The Gradient tool paints a smooth transition between your current foreground and background colors on the canvas.
+
+You can activate the Gradient tool from the canvas toolbar.
+
+## Where Gradient Draws
+
+Gradient only draws into the **active raster layer**:
+
+- It does not draw into inpaint masks.
+- It does not draw into other non-raster layer types.
+- The result is always clipped to the current **generation bounding box**.
+
+If a raster layer is not selected, the tool is unavailable.
+
+## Common Behavior
+
+- Click and drag to define the gradient.
+- Release the pointer to commit the gradient.
+- Press Esc to discard the in-progress gradient.
+- Hold Alt to temporarily switch to the color picker.
+- Hold Space to temporarily switch to panning.
+
+The Gradient tool uses the current **FG/BG color pair**:
+
+- The **active** color swatch becomes the start color.
+- The **inactive** color swatch becomes the end color.
+
+## Gradient Modes
+
+
+
+ Click and drag to set the gradient direction. The drag defines the transition from the start color to the end
+ color.
+
+
+ Click to place the center, then drag outward to set the radius. The gradient fades from the start color at the
+ center to the end color toward the outside.
+
+
+
+## Clip Gradient
+
+The toolbar includes a **Clip Gradient** toggle:
+
+- **Enabled:** Limits the gradient to the dragged region.
+- **Disabled:** Lets the gradient extend across the full current bounding box.
+
+In practice:
+
+- A clipped **linear** gradient is limited to the span you dragged.
+- A clipped **radial** gradient is limited to the circle you dragged out.
+- With clipping disabled, both modes can be used to wash the entire bbox with a full gradient transition.
+
+## Practical Examples
+
+- Use **Linear** for sky fades, shadow ramps, and broad directional lighting.
+- Use **Radial** for vignettes, glows, spotlights, and soft falloff around a focal point.
+- Disable **Clip Gradient** when you want a full-bbox color transition.
+- Keep **Clip Gradient** enabled when you only want to affect a localized area.
+
+## Summary
+
+The Gradient tool is a raster-only canvas tool for painting linear and radial color transitions. Use it when you want
+soft blends between your FG and BG colors, and use **Clip Gradient** to decide whether the effect stays local or fills
+the full bbox.
diff --git a/docs/src/content/docs/features/Canvas/lasso-tool.mdx b/docs/src/content/docs/features/Canvas/lasso-tool.mdx
new file mode 100644
index 00000000000..7cc676aa6f9
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/lasso-tool.mdx
@@ -0,0 +1,77 @@
+---
+title: Lasso Tool
+description: Learn how to create and refine inpaint masks with the Lasso tool.
+lastUpdated: 2026-05-15
+sidebar:
+ order: 2
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+The Lasso tool is the canvas's dedicated masking tool. It always draws into **inpaint mask layers** and is designed
+for quickly defining irregular regions for inpainting.
+
+You can activate the Lasso tool from the canvas toolbar or with the default hotkey L .
+
+## Where Lasso Draws
+
+Lasso always targets an **enabled inpaint mask**:
+
+- If an enabled inpaint mask is currently selected, Lasso draws into that mask.
+- If no enabled inpaint mask is available, Lasso creates a new inpaint mask automatically and commits the contour
+ there.
+
+:::note
+If a disabled inpaint mask is selected, Lasso does not draw into the disabled mask. It creates a new enabled mask for
+the next contour instead.
+:::
+
+## Common Behavior
+
+- Lasso always commits a **closed contour**.
+- Hold Ctrl on Windows/Linux or Cmd on macOS to switch to **subtractive** mode and remove area
+ from the mask instead of adding to it.
+- Press Esc to cancel the current lasso session.
+- Hold Space during an active session to pan the viewport without discarding the unfinished contour.
+
+## Lasso Modes
+
+
+
+ Click and drag to sketch an irregular contour. Releasing the pointer closes and commits the contour automatically.
+
+
+ Click to place vertices. Click the first point to close and commit the contour. Hold Shift while
+ placing the next edge to snap it to horizontal, vertical, and 45 degree angles.
+
+
+
+## Moving and Panning During Drawing
+
+The Lasso tool uses Space for panning in both modes:
+
+- **Freehand:** While drawing, hold Space to pan the viewport without discarding the unfinished contour.
+ Release Space to continue drawing.
+- **Polygon:** During an active polygon session, hold Space to pan the viewport without discarding the
+ unfinished contour. Release Space and continue placing points.
+
+This is especially useful when drawing large mask regions that extend beyond the current viewport.
+
+## Working With Masks
+
+- Use **Freehand** for organic shapes like hair, smoke, foliage, fabric, and quick blocking.
+- Use **Polygon** when you need straight edges and deliberate corner placement.
+- Use **subtractive mode** to trim or punch holes in an existing inpaint mask.
+- Use Lasso when you want mask-first editing behavior without first creating a mask layer by hand.
+
+## Practical Notes
+
+- Polygon mode shows the starting point so you can close the contour precisely.
+- After at least three polygon points, moving near the start point lets you click it to finish the shape.
+- Freehand is faster for loose silhouettes. Polygon is better when edge placement matters.
+
+## Summary
+
+The Lasso tool is the fastest way to create and refine inpaint masks on the canvas. Use Freehand for organic regions,
+Polygon for hard edges, and hold Ctrl /Cmd whenever you need to subtract from the mask instead of
+adding to it.
diff --git a/docs/src/content/docs/features/Canvas/layers-and-drops.mdx b/docs/src/content/docs/features/Canvas/layers-and-drops.mdx
new file mode 100644
index 00000000000..eadc002d696
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/layers-and-drops.mdx
@@ -0,0 +1,35 @@
+---
+title: Layer Tips
+sidebar:
+ order: 6
+---
+
+A couple of layer-related behaviors that aren't obvious from the canvas UI alone.
+
+## Drag & drop targets
+
+Dragging an image onto the canvas reveals **five** drop zones, arranged as two zones on top and three on the bottom:
+
+| Top row | |
+| :--- | :--- |
+| **New Raster Layer** | Create a regular raster layer from the dropped image. |
+| **New Control Layer** | Create a control layer from the dropped image. |
+
+| Bottom row | |
+| :--- | :--- |
+| **New Regional Reference** | Use the image as a regional reference. |
+| **New Inpaint Mask** | Create a new inpaint mask layer using the image as the mask source. |
+| **New Resized Control Layer** | Create a control layer resized to the current canvas dimensions. |
+
+You can drop from the gallery, from disk, or from any panel that shows a draggable image.
+
+## Lock transparency on raster layers
+
+Each raster layer has a **Lock Transparency** toggle (drop icon) in its layer header. When enabled, brush strokes only affect existing non-transparent pixels — painting over transparent areas does nothing. This behaves like Photoshop's "Lock Transparent Pixels".
+
+Typical uses:
+
+- **Recolor an existing shape** without bleeding paint into the empty space around it.
+- **Refine details on a subject** that was painted on an otherwise transparent layer, with no risk of growing its silhouette.
+
+Toggle it off to resume normal painting. The lock is per-layer, so different layers can be locked or unlocked independently. Pressure-sensitive pen input and undo/redo both respect the lock.
diff --git a/docs/src/content/docs/features/Canvas/run-workflow.mdx b/docs/src/content/docs/features/Canvas/run-workflow.mdx
new file mode 100644
index 00000000000..4134072c7f8
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/run-workflow.mdx
@@ -0,0 +1,70 @@
+---
+title: Run Workflow on Canvas
+sidebar:
+ order: 5
+---
+
+import { Steps } from '@astrojs/starlight/components';
+
+You can run any workflow against a raster layer directly from the canvas. The selected layer is passed in as the workflow's image input, and the results land in the canvas staging area where you can review and accept them — without leaving the canvas tab.
+
+## Requirements for a workflow
+
+For a workflow to be available from the canvas, it must satisfy three conditions:
+
+1. **Form Builder is enabled.** The workflow's parameters are presented through the Form Builder UI when the workflow is launched from the canvas, so the workflow needs to have a form configured.
+2. **At least one image input field.** The layer you right-click on is passed into the first eligible image field as the workflow's input.
+3. **At least one `Canvas Output` node.** This is the node that marks which images should be routed back to the canvas staging area.
+
+Workflows that do not meet all three are filtered out of the canvas workflow selector.
+
+## The `Canvas Output` node
+
+`Canvas Output` is a dedicated workflow node that explicitly marks the images you want shown in the canvas staging area. Add it at the end of any branch whose output should appear on the canvas.
+
+A workflow can include **multiple `Canvas Output` nodes**. Each one becomes its own entry in the staging area, with an individually selectable thumbnail. You can navigate between entries with the arrow keys and accept just one of them onto the canvas.
+
+:::note[Why an explicit node?]
+Earlier versions detected output images heuristically (by scanning for `board` fields). That was fragile and caused unrelated nodes — for example, `save_image` — to be mistaken for canvas outputs. `Canvas Output` makes the routing intentional.
+:::
+
+## Running a workflow
+
+
+1. On the canvas, **right-click a raster layer** to open its context menu.
+2. Choose **Run Workflow**.
+3. Pick a workflow from the list. Only workflows that meet the [requirements](#requirements-for-a-workflow) appear here.
+4. Adjust any exposed parameters in the form. All form field types are supported: text, numbers, booleans, enums, schedulers, boards, models, and images.
+5. Click **Run**. The workflow is queued and the results stream into the staging area as they complete.
+
+
+The current layer is automatically passed into the workflow's image input — you do not need to select an image manually.
+
+## Reviewing and accepting results
+
+Results appear in the canvas staging area strip at the bottom of the canvas:
+
+- If the workflow has a single `Canvas Output`, you get one thumbnail per run.
+- If it has multiple `Canvas Output` nodes, each run produces multiple thumbnails, one per output node.
+- Use the staging area's next / previous controls (or arrow keys) to cycle through entries. Navigation wraps across run boundaries.
+- Click **Accept** to commit the currently selected entry onto the canvas. Only that single image is committed — siblings stay in staging until you accept or discard them.
+
+## Troubleshooting
+
+### My workflow doesn't appear in the selector
+
+Check, in order:
+
+- The workflow has Form Builder enabled.
+- The workflow has at least one image input field.
+- The workflow contains at least one `Canvas Output` node.
+
+If any of these is missing, the workflow is hidden.
+
+### Queueing fails with a "BoardField" validation error
+
+This was a known issue with workflows that combined `save_image` and `canvas_output` nodes. It is fixed — update Invoke and try again.
+
+### Errors during execution
+
+Workflow errors are surfaced as toasts and the staging area is cleaned up so it returns to a usable state. Open the queue panel for the full error message.
diff --git a/docs/src/content/docs/features/Canvas/shapes-tool.mdx b/docs/src/content/docs/features/Canvas/shapes-tool.mdx
new file mode 100644
index 00000000000..ba3fb782a07
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/shapes-tool.mdx
@@ -0,0 +1,99 @@
+---
+title: Shapes Tool
+description: Learn how to draw filled shapes on raster and inpaint mask layers with the Shapes tool.
+lastUpdated: 2026-05-11
+sidebar:
+ order: 1
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+The Shapes tool is a general-purpose filled-shape drawing tool for the canvas. It replaces the old Rectangle tool and
+adds four shape modes under a single toolbar button:
+
+- **Rect**
+- **Oval**
+- **Polygon**
+- **Freehand**
+
+You can activate the Shapes tool from the canvas toolbar or with the default hotkey U .
+
+## Where Shapes Draws
+
+Shapes always draws into the **active raster target**:
+
+- On a regular raster layer, Shapes adds filled pixels to that layer.
+- On an active inpaint mask layer, Shapes draws directly into the mask.
+
+:::note
+Shapes overlaps with some Lasso workflows on mask layers, but the tools are not identical. Lasso is still the more
+specialized masking tool and can create a new mask layer automatically when one does not already exist. See the
+[Lasso tool guide](./lasso-tool/) for mask-specific behavior.
+:::
+
+## Common Behavior
+
+- Shapes preview live while you draw.
+- The fill color uses the current active color.
+- On a raster layer, the active color's alpha is respected when adding pixels.
+- Hold Ctrl on Windows/Linux or Cmd on macOS to switch to **subtractive** mode and cut pixels
+ out of the active layer.
+- In subtractive mode, alpha is ignored and the shape fully clears pixels.
+- Press Esc to cancel the current shape session.
+
+:::tip
+When subtractive mode is active, the canvas cursor shows a small minus badge so you can tell at a glance that the next
+shape will erase instead of fill.
+:::
+
+## Shape Modes
+
+
+
+ Drag to draw a rectangle. Hold Shift to constrain to a square. Hold Alt to draw from the
+ center instead of from a corner.
+
+
+ Drag to draw an ellipse. Hold Shift to constrain to a perfect circle. Hold Alt to draw from
+ the center.
+
+
+ Click to place vertices. Click the first point to close and commit the shape. Hold Shift to snap the
+ pending edge to horizontal, vertical, and 45 degree angles.
+
+
+ Click and drag to sketch a filled freehand contour. Release the pointer to commit the shape.
+
+
+
+## Moving and Panning During Drawing
+
+The Shapes tool supports different Space behavior depending on the current mode:
+
+- **Rect / Oval:** While the pointer is still down, hold Space to move the uncommitted shape instead of
+ resizing it. Release Space to continue resizing.
+- **Polygon / Freehand:** Hold Space during an active session to pan the viewport without discarding the
+ unfinished shape.
+
+This is especially useful when drawing large shapes that extend beyond the current viewport.
+
+## Color Picking While Using Shapes
+
+The Alt key behaves differently depending on the active Shapes mode:
+
+- **Rect / Oval:** Before you start dragging, Alt can be used for the temporary color-picker quick-switch.
+ Once a drag is active, Alt is reserved for drawing from the center.
+- **Polygon:** Alt remains available for the temporary color-picker quick-switch between vertex placements.
+- **Freehand:** Alt is available before the stroke starts, but not during an active stroke.
+
+## Practical Examples
+
+- Use **Rect** or **Oval** to quickly add clean filled regions.
+- Use **Polygon** when you need straight edges and deliberate corner placement.
+- Use **Freehand** for irregular organic regions.
+- Use **subtractive mode** to cut holes back out of an existing filled region.
+
+## Summary
+
+The Shapes tool is the fastest way to add filled geometric or freeform regions to canvas layers. Use it for structured
+fills, mask authoring, and precise subtractive edits without switching away from the current raster target.
diff --git a/docs/src/content/docs/features/Canvas/text-tool.mdx b/docs/src/content/docs/features/Canvas/text-tool.mdx
new file mode 100644
index 00000000000..6e223767d8e
--- /dev/null
+++ b/docs/src/content/docs/features/Canvas/text-tool.mdx
@@ -0,0 +1,33 @@
+---
+title: Text Tool
+sidebar:
+ order: 3
+---
+
+import { LinkCard } from '@astrojs/starlight/components';
+
+## Font selection
+
+The Text tool uses a set of predefined font stacks. When you choose a font, the app resolves the first available font on your system from that stack and uses it for both the editor overlay and the rasterized result. This provides consistent styling across platforms while still falling back to safe system fonts if a preferred font is missing.
+
+## Size and spacing
+
+- **Size** controls the font size in pixels.
+- **Spacing** controls the line height multiplier (Dense, Normal, Spacious). This affects the distance between lines while editing the text.
+
+## Uncommitted state
+
+While text is uncommitted, it remains editable on-canvas. Access to other tools is blocked. Switching to other tabs (Generate, Upascaling, Workflows etc.) discards the text. The uncommitted box can be moved and rotated:
+
+- **Move:** Hold Ctrl (Windows/Linux) or Command (macOS) and drag to move the text box.
+- **Rotate:** Drag the rotation handle above the box. Hold **Shift** while rotating to snap to 15 degree increments.
+
+The text is committed to a raster layer when you press **Enter**. Press **Esc** to discard the current text session.
+
+## For Developers
+
+
diff --git a/docs/src/content/docs/features/External Models/alibabacloud.mdx b/docs/src/content/docs/features/External Models/alibabacloud.mdx
new file mode 100644
index 00000000000..0809b60442d
--- /dev/null
+++ b/docs/src/content/docs/features/External Models/alibabacloud.mdx
@@ -0,0 +1,54 @@
+---
+title: Alibaba Cloud DashScope
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+Invoke supports Alibaba Cloud's **DashScope** image generation service, giving access to the **Qwen Image** family and **Wan 2.6** text-to-image. Qwen Image is particularly strong at bilingual (Chinese / English) text rendering.
+
+## Getting an API Key
+
+
+1. Sign in to [Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/product/modelstudio) (the international DashScope portal).
+2. Enable **DashScope** and activate the image generation models you plan to use.
+3. Create an API key from the **API Keys** section of the console.
+
+
+## Configuration
+
+Add your key to `api_keys.yaml` in your Invoke root directory:
+
+```yaml
+external_alibabacloud_api_key: "your-dashscope-api-key"
+
+# Optional — default is the international endpoint. Use the China endpoint if your account lives there:
+# https://dashscope.aliyuncs.com
+external_alibabacloud_base_url: "https://dashscope-intl.aliyuncs.com"
+```
+
+Restart Invoke for the change to take effect.
+
+:::note[International vs. China endpoints]
+DashScope has separate international (`dashscope-intl.aliyuncs.com`) and China (`dashscope.aliyuncs.com`) deployments. Your API key only works on the deployment it was issued on — if you get authentication errors, check that `external_alibabacloud_base_url` matches.
+:::
+
+## Available Models
+
+| Model | Modes | Aspect Ratios | Batch | Notes |
+| --- | --- | --- | --- | --- |
+| **Qwen Image 2.0 Pro** | txt2img | 1:1, 4:3, 3:4, 16:9, 9:16 | up to 4 | Best quality, 2K output, excellent bilingual text. |
+| **Qwen Image 2.0** | txt2img | 1:1, 4:3, 3:4, 16:9, 9:16 | up to 4 | Faster / cheaper 2K sibling of 2.0 Pro. |
+| **Qwen Image Max** | txt2img | 1:1, 4:3, 3:4, 16:9, 9:16 | up to 4 | High quality at ~1.3K native size. |
+| **Qwen Image Edit Max** | txt2img (with reference images) | 1:1, 4:3, 3:4, 16:9, 9:16 | up to 4 | Reference-image-driven generation with industrial / geometric reasoning. Accepts up to 14 reference images. |
+| **Wan 2.6 Text-to-Image** | txt2img | 1:1, 4:3, 3:4, 16:9, 9:16 | up to 4 | Photorealistic T2I at 1K. |
+
+All models support **seed**. Negative prompts are not currently plumbed through to DashScope, so the negative prompt input is ignored for these providers. None of the Alibaba Cloud models support img2img (denoising-strength edits) or inpaint (mask-based edits) in Invoke today.
+
+## Tips
+
+
+1. Bilingual prompts. Qwen Image is unusually good at rendering Chinese text and mixed-language prompts — it's a strong choice when your prompt or desired output contains non-Latin script.
+2. Reference-image input is only accepted by Qwen Image Edit Max — provide images via the reference-images panel. Masks and denoising strength are not supported for any Alibaba Cloud model.
+3. Batching is capped at 4 images per request. Larger batches are split across multiple API calls.
+4. Costs vary per model — Qwen Image 2.0 Pro is the most expensive, Qwen Image 2.0 the cheapest of the 2.0 family. Check Alibaba Cloud's pricing page before running large batches.
+
diff --git a/docs/src/content/docs/features/External Models/gemini.mdx b/docs/src/content/docs/features/External Models/gemini.mdx
new file mode 100644
index 00000000000..53067376488
--- /dev/null
+++ b/docs/src/content/docs/features/External Models/gemini.mdx
@@ -0,0 +1,48 @@
+---
+title: Google Gemini
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+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 | Yes | 10 aspect ratios, fixed per-ratio resolutions. |
+| **Gemini 3 Pro Image Preview** | txt2img | Up to 14 (6 object + 5 character) | 1K / 2K / 4K resolution presets. |
+| **Gemini 3.1 Flash Image Preview** | txt2img | Up to 14 (10 object + 4 character) | 512 / 1K / 2K / 4K resolution presets. |
+
+Reference-image input is used to condition generation but counts as txt2img — neither img2img (denoising strength) nor inpaint (mask) is supported for Gemini.
+
+All Gemini models are single-image-per-request — batch size is fixed at 1. To generate multiple variations, queue multiple invocations.
+
+## Tips
+
+
+1. Reference images are sent directly to the API as inlined PNG data. Large references increase request latency and cost — crop tightly where possible.
+2. 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.
+3. Pricing varies by model and region. Check Google's documentation before running large batches.
+
diff --git a/docs/src/content/docs/features/External Models/index.mdx b/docs/src/content/docs/features/External Models/index.mdx
new file mode 100644
index 00000000000..358b68fe68e
--- /dev/null
+++ b/docs/src/content/docs/features/External Models/index.mdx
@@ -0,0 +1,58 @@
+---
+title: 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](/features/external-models/gemini/) — Gemini 2.5 Flash Image, Gemini 3 Pro Image Preview, Gemini 3.1 Flash Image Preview
+- [OpenAI](/features/external-models/openai/) — GPT Image 1 / 1.5 / 1-mini, DALL·E 3
+- [BytePlus Seedream](/features/external-models/seedream/) — Seedream 5.0, 5.0 Lite, 4.5, 4.0
+- [Alibaba Cloud DashScope](/features/external-models/alibabacloud/) — Qwen Image 2.0 / 2.0 Pro / Max / Edit Max, Wan 2.6 T2I
+
+## 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`). Inpainting is not currently supported by any external provider.
+- 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.
diff --git a/docs/src/content/docs/features/External Models/openai.mdx b/docs/src/content/docs/features/External Models/openai.mdx
new file mode 100644
index 00000000000..2ee4628ebd3
--- /dev/null
+++ b/docs/src/content/docs/features/External Models/openai.mdx
@@ -0,0 +1,65 @@
+---
+title: OpenAI
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+Invoke supports OpenAI's image generation models — the GPT Image family and DALL·E 3 — through the OpenAI API.
+
+:::note[DALL·E 2 removed]
+DALL·E 2 was deprecated by OpenAI and is scheduled for shutdown on 2026-05-12. It is no longer offered as a starter model in Invoke.
+:::
+
+## 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 | 1:1, 3:2, 2:3 | up to 10 | Fastest and cheapest GPT Image model. |
+| **GPT Image 1** | txt2img, img2img | 1:1, 3:2, 2:3 | up to 10 | Highest quality of the GPT Image family. |
+| **GPT Image 1 Mini** | txt2img, img2img | 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. |
+
+Inpainting (mask-based editing) is not currently supported for any OpenAI model in Invoke. img2img on the GPT Image family routes through the `/v1/images/edits` endpoint without a mask.
+
+## 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
+
+
+1. Batching on GPT Image tops out at 10 per request. Larger batches are split into multiple API calls.
+2. Costs can climb quickly with high-quality GPT Image generations. Start with GPT Image 1 Mini when iterating on prompts.
+3. Rate limits from OpenAI surface as failed invocations — retry after a short wait.
+
diff --git a/docs/src/content/docs/features/External Models/seedream.mdx b/docs/src/content/docs/features/External Models/seedream.mdx
new file mode 100644
index 00000000000..de25273d289
--- /dev/null
+++ b/docs/src/content/docs/features/External Models/seedream.mdx
@@ -0,0 +1,68 @@
+---
+title: BytePlus Seedream
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+Invoke supports BytePlus's **Seedream** image generation family through the BytePlus Ark API. Seedream is a strong fit for 2K/4K generations and multi-reference image composition.
+
+## Getting an API Key
+
+
+1. Open the [BytePlus Console](https://console.byteplus.com/) and sign in.
+2. Enable the **Ark** (model serving) product.
+3. Create an API key with access to the Seedream models you plan to use.
+
+
+## Configuration
+
+Add your key to `api_keys.yaml` in your Invoke root directory:
+
+```yaml
+external_seedream_api_key: "your-seedream-api-key"
+
+# Optional — change only if you need a different regional endpoint
+external_seedream_base_url: "https://ark.ap-southeast.bytepluses.com"
+```
+
+Restart Invoke for the change to take effect.
+
+## Available Models
+
+| Model | Modes | Reference Images | Batch | Native Size |
+| --- | --- | --- | --- | --- |
+| **Seedream 5.0** | txt2img, img2img | up to 14 | up to 15 | 2K |
+| **Seedream 5.0 Lite** | txt2img, img2img | up to 14 | up to 15 | 2K |
+| **Seedream 4.5** | txt2img, img2img | up to 14 | up to 15 | 2K |
+| **Seedream 4.0** | txt2img, img2img | up to 14 | up to 15 | 2K |
+
+The 4.x / 5.x models are batch-capable and accept up to 14 reference images per request.
+
+:::note[Model IDs]
+BytePlus uses date-stamped model IDs (e.g. `seedream-5-0-260128`). When BytePlus releases a new dated revision, the starter model IDs in Invoke need to be updated. Seedream 3.0 T2I (`seedream-3-0-t2i-250415`) was deprecated by BytePlus and replaced by Seedream 4.0.
+:::
+
+### Supported Aspect Ratios
+
+All Seedream models share the same aspect ratio set: `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9`, rendered at 2K.
+
+## Provider-Specific Options
+
+Seedream exposes two provider-specific toggles in the parameters panel:
+
+- **Watermark** — When enabled, BytePlus adds a small watermark to the output. Off by default.
+- **Optimize Prompt** — When enabled, BytePlus rewrites your prompt server-side for better generation quality. Useful for short prompts; disable if you want the exact wording preserved.
+
+**Seed** and **guidance scale** are not accepted by the 4.x / 5.x family.
+
+## Reference Images
+
+4.x and 5.x Seedream models accept up to 14 reference images alongside the prompt. Invoke's standard reference-image panel is used — drag images in, and they are forwarded as base64 PNGs to the API.
+
+## Tips
+
+
+1. For multi-image composition (e.g. character + product), Seedream 4.5 is a good default.
+2. When running large batches (`num_images > 1` on 4.x / 5.x), Invoke uses the `sequential_image_generation` API flag — each image is returned as it completes.
+3. Set `external_seedream_base_url` if you need to route through a region-specific Ark endpoint.
+
diff --git a/docs/src/content/docs/features/Multi-User Mode/admin-guide.mdx b/docs/src/content/docs/features/Multi-User Mode/admin-guide.mdx
new file mode 100644
index 00000000000..bcef946fb3c
--- /dev/null
+++ b/docs/src/content/docs/features/Multi-User Mode/admin-guide.mdx
@@ -0,0 +1,642 @@
+---
+title: Multi-User Administrator Guide
+description: How to set up and manage a multi-user InvokeAI installation.
+sidebar:
+ order: 4
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+## Overview
+
+This guide is for administrators managing a multi-user InvokeAI
+installation. It covers initial setup, user management, security best
+practices, and troubleshooting.
+
+## Prerequisites
+
+Before enabling multi-user support, ensure you have:
+
+- InvokeAI installed and running
+- Access to the server filesystem (for initial setup)
+- Understanding of your deployment environment
+- Backup of your existing data (recommended)
+
+## Initial Setup
+
+### Activating Multiuser Mode
+
+To put InvokeAI into multiuser mode, you will need to add the option `multiuser: true` to its configuration file. This file is located at `INVOKEAI_ROOT/invokeai.yaml`. With the InvokeAI backend halted, add the new configuration option to the end of the file with a text editor so that it looks like this:
+
+```yaml
+# Internal metadata - do not edit:
+schema_version: 4.0.2
+
+# Enable/disable multi-user mode
+multiuser: true
+```
+
+Then restart the InvokeAI server backend from the command line or using the launcher.
+
+:::note[Reverting to single-user mode]
+If at any time you wish to revert to single-user mode, simply comment out the `multiuser` line, or change "true" to "false". Then restart the server. Because of the way that browsers cache pages, users with open InvokeAI sessions may need to force-refresh their browsers.
+:::
+
+### First Administrator Account
+
+When InvokeAI starts for the first time in multi-user mode, you'll see the **Administrator Setup** dialog.
+
+**Setup Steps:**
+
+
+1. **Email Address**: Enter a valid email address (this becomes your username)
+
+ - Example: `admin@example.com` or `admin@localhost` for testing
+ - Must be a valid email format
+ - Cannot be changed later without database access
+
+2. **Display Name**: Enter a friendly name
+
+ - Example: "System Administrator" or your real name
+ - Can be changed later in your profile
+ - Visible to other users in shared contexts
+
+3. **Password**: Create a strong administrator password
+
+ - **Minimum requirements:**
+
+ - At least 8 characters long
+ - Contains uppercase letters (A-Z)
+ - Contains lowercase letters (a-z)
+ - Contains numbers (0-9)
+
+ - **Recommended:**
+
+ - Use 12+ characters
+ - Include special characters (!@#$%^&*)
+ - Use a password manager to generate and store
+ - Don't reuse passwords from other services
+
+4. **Confirm Password**: Re-enter the password
+
+5. Click **Create Administrator Account**
+
+
+:::caution[Important]
+Store these credentials securely! The first administrator account can reset the password to something new, but cannot retrieve a lost one.
+:::
+
+### Configuration
+
+InvokeAI can run in single-user or multi-user mode, controlled by the `multiuser` configuration option in `invokeai.yaml`:
+
+```yaml
+# Enable/disable multi-user mode
+multiuser: true # Enable multi-user mode (requires authentication)
+
+# Optional password policy
+strict_password_checking: true # Enforce uppercase/lowercase/number requirements
+```
+
+JWT secrets are generated automatically and stored in the database. Session lifetimes default to 24 hours, or 7 days when the user selects "Remember me". See Secret Key Management below if you need to rotate the JWT secret.
+
+:::caution[Mode Switching Behavior]
+**Switching to Single-User Mode:** If boards or images were created in multi-user mode, they will all be combined into a single unified view when switching to single-user mode.
+
+**Switching to Multi-User Mode:** Legacy boards and images created under single-user mode will be owned by an internal user named "system." Only the Administrator will have access to these legacy assets. A utility to migrate these legacy assets to another user will be part of a future release.
+:::
+
+### Migration from Single-User
+
+When upgrading from a single-user installation or switching modes:
+
+
+1. **Automatic Migration**: The database will automatically migrate to multi-user schema when multi-user mode is first enabled
+2. **Legacy Data Ownership**: Existing data (boards, images, workflows) created in single-user mode is assigned to an internal user named "system"
+3. **Administrator Access**: Only administrators will have access to legacy "system"-owned assets when in multi-user mode
+4. **No Data Loss**: All existing content is preserved
+
+
+**Migration Process:**
+
+```bash
+# Backup your database first
+cp databases/invokeai.db databases/invokeai.db.backup
+
+# Enable multi-user mode in invokeai.yaml
+# multiuser: true
+
+# Start InvokeAI (migration happens automatically)
+invokeai-web
+
+# Complete the administrator setup dialog
+# Legacy data will be owned by "system" user
+```
+
+:::note[Legacy Asset Migration]
+A utility to migrate legacy "system"-owned assets to specific user accounts will be available in a future release. Until then, administrators can access and manage all legacy content.
+:::
+
+## User Management
+
+### Creating Users
+
+Administrators can create and modify users (including other
+administrators) via a built-in web interface or using command-line
+scripts.
+
+#### **Via the Web Frontend:**
+
+Please see the Multi-User Guide's section on [Adding and Modifying Users](./user-guide#adding-and-modifying-users)
+for a walk-through.
+
+#### **Via Command Line Scripts:**
+
+##### Command-line User Management Scripts
+
+Administrators can also use a series of command-line scripts to add, modify, or delete users. If you use the launcher, click the ">" icon to enter the command-line interface. Otherwise, if you are a native command-line user, activate the InvokeAI environment from your terminal.
+
+All command-line arguments are optional. The scripts will prompt you to provide any missing arguments.
+
+The commands are:
+
+| Name | Function | Example CLI Usage |
+|--------------------|---------------|--------------------|
+|**invoke-useradd** | add a user | `invoke-useradd --email user@example.com --name "Example User" --password "badpassword"` |
+|**invoke-usermod** | modify a user | `invoke-usermod --email user@example.com --name "Mr. Example User" --password "8adsf2**%"` |
+|**invoke-userdel** | delete a user | `invoke-userdel --email user@example.com --force` |
+|**invoke-userlist** | list all users| `invoke-userlist` |
+
+Pass the `--help` argument to get the usage of each script. For example:
+
+```bash
+> invoke-useradd --help
+usage: invoke-useradd [-h] [--root ROOT] [--email EMAIL] [--password PASSWORD] [--name NAME] [--admin]
+
+Add a user to the InvokeAI database
+
+options:
+ -h, --help show this help message and exit
+ --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: the $INVOKEAI_ROOT environment
+ variable, the active virtual environment's parent directory, or $HOME/invokeai.
+ --email EMAIL, -e EMAIL
+ User email address
+ --password PASSWORD, -p PASSWORD
+ User password
+ --name NAME, -n NAME User display name (optional)
+ --admin, -a Make user an administrator
+
+If no arguments are provided, the script will run in interactive mode.
+```
+
+:::danger[Data Loss]
+Deleting a user removes the user record and cascades to their sessions, board shares, sent
+invitations, and per-user client state. It does **not** delete the boards, images, workflows, queue
+items, or style presets they created — those rows remain in the database, owned by a user_id that no
+longer exists, and will not appear in any user's gallery. Physical image files in `outputs/images`
+are also left in place until a gallery maintenance script is run to remove orphan images.
+
+If you want their content gone as well, reassign or delete it before deleting the user. Back up the
+database first if recovery might be needed.
+:::
+
+### Viewing User Activity
+
+**Queue Management:**
+
+There is no separate admin-only queue view. When signed in as an administrator, the regular queue
+panel automatically shows every user's queue items (each item is labelled with the submitting user's
+display name or email), and you can cancel or clear any of them. There is no built-in UI to filter
+the queue by user; use your browser's find-in-page to scan by name if needed.
+
+## Model Management
+
+As an administrator, you have full access to the [Model
+Manager](/concepts/models) and can install, edit and delete
+models just as in single-user mode. Unprivileged users, however, can
+view the models previously installed, but cannot add or modify them.
+
+## Security
+
+:::note[Strict Password Checking]
+It is recommended that you enable strict password checking. This will
+force all users to select good passwords that follow the
+"minimal requirements" below. Do this by adding `strict_password_checking` to
+the `invokeai.yaml` configuration file:
+
+```
+strict_password_checking: true
+```
+:::
+
+### Password Policies
+
+**Minimal Requirements:**
+
+- Minimum 8 characters
+- Must contain uppercase letters
+- Must contain lowercase letters
+- Must contain numbers
+
+If `strict_password_checking` is active (recommended), then these
+minimal requirements will be enforced and users will not be able to
+proceed until they have picked a password that satisfies
+them. Otherwise, the user will simply be warned when they use a weak
+password.
+
+**Recommended Policies:**
+
+- Require 12+ character passwords
+- Include special characters
+- Implement password rotation every 90 days
+- Prevent password reuse
+
+### Session Management
+
+**Session Security and Token Management:**
+
+This system uses stateless JWT tokens with HMAC signatures to identify users after they provide their initial credentials. The tokens will persist for 24 hours by default, or for 7 days if the user clicks the "Remember me" checkbox at login. Expired tokens are automatically rejected and the user will have to log in again.
+
+At the client side, tokens are stored in browser localStorage. Logging out clears them. No server-side session storage is required.
+
+The tokens include the user's ID, email, and admin status, along with an HMAC signature.
+
+### Secret Key Management
+
+**Important:** The JWT secret key must be kept confidential.
+
+To generate tokens, each InvokeAI instance has a distinct secret JWT
+key that must be kept confidential. The key is stored in the
+`app_settings` table of the InvokeAI database within a field value
+named `jwt_secret`.
+
+The secret key is automatically generated during database creation or
+migration. If you wish to change the key, you may generate a
+replacement using either of these commands:
+
+```bash
+# Python
+python -c "import secrets; print(secrets.token_urlsafe(32))"
+
+# OpenSSL
+openssl rand -base64 32
+```
+
+Then cut and paste the printed secret into this Sqlite3 command:
+
+```bash
+sqlite3 INVOKE_ROOT/databases/invokeai.db 'update app_settings set value="THE_SECRET" where key="jwt_secret"'
+```
+
+(replace INVOKE_ROOT with your InvokeAI root directory and THE_SECRET with the new secret).
+
+After this, restart the server. All logged in users will be logged out and will need to provide their usernames and passwords again.
+
+### Hosting a Shared InvokeAI Instance
+
+The multiuser feature allows you to run an InvokeAI backend that can be accessed by your friends and family across your home network. It is also possible to host a backend that is accessible over the Internet.
+
+By default, InvokeAI runs on `localhost`, IP address `127.0.0.1`, which is only accessible to browsers running on the same machine as the backend. To make the backend accessible to any machine on your home or work LAN, add the line `host: 0.0.0.0` to the InvokeAI configuration file, usually stored at `INVOKE_ROOT/invokeai.yaml`.
+
+Here is a minimal example.
+
+```yaml
+# Internal metadata - do not edit:
+schema_version: 4.0.2
+
+# Put user settings here - see https://invoke-ai.github.io/InvokeAI/configuration/:
+multiuser: true
+host: 0.0.0.0
+```
+
+After relaunching the backend you will be able to reach the server from other machines on the LAN using the server machine's IP address or hostname and port 9090.
+
+#### Making InvokeAI Accessible to the Internet
+
+:::danger[Use at your own risk]
+The InvokeAI team has done its best to make the software free of exploitable bugs, but the software has not undergone a rigorous security audit or intrusion testing. Use at your own risk.
+:::
+
+It is also possible to create a (semi) public server accessible from the Internet. The details of how to do this depend very much on your home or corporate router/firewall system and are beyond the scope of this document.
+
+If you expose InvokeAI to the Internet, there are a number of precautions to take. Here is a brief list of recommended network security practices.
+
+**HTTPS Configuration:**
+
+For internet deployments, always use HTTPS:
+
+```nginx
+# Use a reverse proxy like nginx or Traefik
+# Example nginx configuration:
+
+server {
+ listen 443 ssl http2;
+ server_name invoke.example.com;
+
+ ssl_certificate /path/to/cert.pem;
+ ssl_certificate_key /path/to/key.pem;
+
+ location / {
+ proxy_pass http://localhost:9090;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # WebSocket support
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+```
+
+**Firewall Rules:**
+
+It is best to restrict access to trusted networks and remote IP addresses, or use a VPN to connect to your home network. Rate limit connections to InvokeAI's authentication endpoint `http://your.host:9090/api/v1/auth/login`.
+
+**Backup and Recovery:**
+
+It is always a good idea to periodically backup your InvokeAI database and images, but especially
+so if the server is publicly accessible to the Internet.
+
+**Manual Backup:**
+
+```bash
+# Stop InvokeAI
+# Copy database file
+cd INVOKE_ROOT
+cp databases/invokeai.db databases/invokeai.db.$(date +%Y%m%d)
+
+# Or create compressed backup
+tar -czf invokeai_backup_$(date +%Y%m%d).tar.gz databases/
+```
+
+**Automated Backup Script:**
+
+```bash
+#!/bin/bash
+# backup_invokeai.sh
+
+INVOKE_ROOT="/path/to/invoke_root"
+BACKUP_DIR="/path/to/backups"
+DB_PATH="$INVOKE_ROOT/databases/invokeai.db"
+DATE=$(date +%Y%m%d_%H%M%S)
+
+# Create backup directory
+mkdir -p "$BACKUP_DIR"
+
+# Copy database
+cp "$DB_PATH" "$BACKUP_DIR/invokeai_$DATE.db"
+
+# Keep only last 30 days
+find "$BACKUP_DIR" -name "invokeai_*.db" -mtime +30 -delete
+
+echo "Backup completed: invokeai_$DATE.db"
+```
+
+**Schedule with cron:**
+
+```bash
+# Edit crontab
+crontab -e
+
+# Add daily backup at 2 AM
+0 2 * * * /path/to/backup_invokeai.sh
+```
+
+**Restore from Backup:**
+
+```bash
+# Stop InvokeAI
+# Replace current database with backup
+cd INVOKE_ROOT
+cp databases/invokeai.db databases/invokeai.db.old # Save current
+cp databases/invokeai_backup.db databases/invokeai.db
+
+# Restart InvokeAI
+invokeai-web
+```
+
+**Disaster Recovery — Complete System Backup:**
+
+Include these directories/files:
+
+- `databases/` — All database files
+- `models/` — Installed models (if locally stored)
+- `outputs/` — Generated images
+- `invokeai.yaml` — Configuration file
+- Any custom scripts or modifications
+
+**Recovery Process:**
+
+
+1. Install InvokeAI on new system
+2. Restore configuration file
+3. Restore database directory
+4. Restore models and outputs
+5. Verify file permissions
+6. Start InvokeAI and test
+
+
+## Troubleshooting
+
+### User Cannot Login
+
+**Symptom:** User reports unable to log in
+
+**Diagnosis:**
+
+1. Verify account exists and is active
+
+ ```bash
+ sqlite3 databases/invokeai.db "SELECT * FROM users WHERE email = 'user@example.com';"
+ ```
+
+2. Check password (have user try resetting)
+3. Verify account is active (`is_active = 1`)
+4. Check for account lockout (if implemented)
+
+**Solutions:**
+
+- Reset user password
+- Reactivate disabled account
+- Verify email address is correct
+- Check system logs for auth errors
+
+### Database Locked Errors
+
+**Symptom:** "Database is locked" errors
+
+**Causes:**
+
+- Concurrent write operations
+- Long-running transactions
+- Backup process accessing database
+- File system issues
+
+**Solutions:**
+
+```bash
+# Check for locks
+fuser databases/invokeai.db
+
+# Increase timeout (in config)
+# Or switch to WAL mode:
+sqlite3 databases/invokeai.db "PRAGMA journal_mode=WAL;"
+```
+
+### Forgotten Admin Password
+
+**Recovery Process:**
+
+
+1. Stop InvokeAI
+2. Direct database access:
+
+ ```bash
+ sqlite3 databases/invokeai.db
+ ```
+
+3. Reset admin password (requires password hash):
+
+ ```sql
+ -- Generate hash first using Python:
+ -- from passlib.context import CryptContext
+ -- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+ -- print(pwd_context.hash("NewPassword123"))
+
+ UPDATE users
+ SET password_hash = '$2b$12$...'
+ WHERE email = 'admin@example.com';
+ ```
+
+4. Restart InvokeAI
+
+
+:::note[Alternative Step 3]
+Remove the admin from the database entirely in order
+to trigger the setup process when InvokeAI restarts:
+
+```sql
+DELETE FROM users
+WHERE email = 'admin@example.com';
+```
+:::
+
+
+### Performance Issues
+
+**Symptom:** Slow generation or UI
+
+**Diagnosis:**
+
+
+1. Check active generation count
+2. Review resource usage (CPU/GPU/RAM)
+3. Check database size and performance
+4. Review network latency
+
+
+**Solutions:**
+
+- Limit concurrent generations
+- Increase hardware resources
+- Optimize database (`VACUUM`, `ANALYZE`)
+- Add indexes for slow queries
+- Consider load balancing
+
+### Migration Failures
+
+**Symptom:** Database migration fails on upgrade
+
+**Prevention:**
+
+- Always backup before upgrading
+- Test migration on copy of database
+- Review migration logs
+
+**Recovery:**
+
+```bash
+# Restore backup
+cp databases/invokeai.db.backup databases/invokeai.db
+
+# Try migration again with verbose logging
+invokeai-web --log-level DEBUG
+```
+
+## Configuration Reference
+
+### Complete Configuration Example for a Public Site
+
+```yaml
+# invokeai.yaml - Multi-user configuration
+
+# Internal metadata - do not edit:
+schema_version: 4.0.2
+
+# Put user settings here
+multiuser: true
+
+# Server
+host: "0.0.0.0"
+port: 9090
+
+# Performance
+enable_partial_loading: true
+precision: float16
+pytorch_cuda_alloc_conf: "backend:cudaMallocAsync"
+hashing_algorithm: blake3_multi
+```
+
+## Frequently Asked Questions
+
+### How many users can InvokeAI support?
+
+The backend will support dozens of concurrent users. However, because the image generation queue is single-threaded, image generation tasks are processed on a first-come, first-serve basis. This means that a user may have to wait for all the other users' image generation jobs to complete before their generation job starts to execute.
+
+A future version of InvokeAI may support concurrent execution on systems with multiple GPUs/graphics cards.
+
+### Can I integrate with existing authentication systems?
+
+OAuth2/OpenID Connect support is planned for a future release. Currently, InvokeAI uses its own authentication system.
+
+### How do I audit user actions?
+
+Full audit logging is planned for a future release. Currently, you can:
+
+- Monitor the generation queue
+- Review database changes
+- Check application logs
+
+### Can users have different model access?
+
+Currently all users can view and use all installed models. Per-user
+model access is a possible enhancement. Please let the development
+team know if you want this feature.
+
+### How do I handle user data when they leave?
+
+Best practice:
+
+
+1. Deactivate the account first
+2. Transfer ownership of shared boards
+3. After transition period, delete the account
+4. Or keep the account deactivated for audit purposes
+
+
+### What's the licensing impact of multi-user mode?
+
+InvokeAI remains under its existing license. Multi-user mode does not change licensing terms.
+
+## Getting Help
+
+### Support
+
+- **General Documentation**: [InvokeAI Docs](https://invoke.ai/)
+- **User Guide**: [For Users](/features/multi-user-mode/user-guide/)
+- **API Guide**: [For Developers](/features/multi-user-mode/api-guide/)
+- **Discord**: [Join Community](https://discord.gg/ZmtBAhwWhy)
+- **GitHub Issues**: [Report Problems](https://github.com/invoke-ai/InvokeAI/issues)
diff --git a/docs/src/content/docs/features/Multi-User Mode/api-guide.mdx b/docs/src/content/docs/features/Multi-User Mode/api-guide.mdx
new file mode 100644
index 00000000000..36083388daf
--- /dev/null
+++ b/docs/src/content/docs/features/Multi-User Mode/api-guide.mdx
@@ -0,0 +1,1236 @@
+---
+title: Multi-User API Guide
+description: How to authenticate and interact with the InvokeAI API in multi-user mode.
+sidebar:
+ order: 5
+---
+import { Steps } from '@astrojs/starlight/components'
+
+## Overview
+
+This guide explains how to interact with InvokeAI's API in both single-user and multi-user modes. The API behavior depends on the `multiuser` configuration setting.
+
+### Single-User vs Multi-User Mode
+
+**Single-User Mode** (`multiuser: false` or option absent):
+
+- No authentication required
+- All API endpoints accessible without tokens
+- Direct API access like previous InvokeAI versions
+- All content visible in unified view
+
+**Multi-User Mode** (`multiuser: true`):
+
+- JWT token authentication required
+- User-scoped access to resources
+- Role-based authorization (admin vs regular user)
+- Data isolation between users
+
+## Authentication (Multi-User Mode Only)
+
+### Authentication Flow
+
+When multi-user mode is enabled, most API endpoints require authentication using JWT bearer tokens. The unauthenticated authentication endpoints are `GET /api/v1/auth/status`, `POST /api/v1/auth/setup`, and `POST /api/v1/auth/login`.
+
+**Authentication Process:**
+
+
+1. **Obtain Token**: POST credentials to `/api/v1/auth/login`
+2. **Store Token**: Save the JWT token securely
+3. **Use Token**: Include token in `Authorization` header for all requests
+4. **Refresh**: Re-authenticate when token expires
+
+
+:::note[Single-User Mode]
+When running in single-user mode (`multiuser: false`), authentication endpoints are not available and authentication headers are not required.
+:::
+
+### Login Endpoint
+
+**Endpoint:** `POST /api/v1/auth/login`
+
+**Request:**
+
+```json
+{
+ "email": "user@example.com",
+ "password": "SecurePassword123",
+ "remember_me": false
+}
+```
+
+**Response (Success):**
+
+```json
+{
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
+ "user": {
+ "user_id": "abc123",
+ "email": "user@example.com",
+ "display_name": "John Doe",
+ "is_admin": false,
+ "is_active": true,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:00:00Z",
+ "last_login_at": "2024-01-15T15:30:00Z"
+ },
+ "expires_in": 86400
+}
+```
+
+**Response (Error):**
+
+```json
+{
+ "detail": "Incorrect email or password"
+}
+```
+
+**Status Codes:**
+
+- `200 OK` — Authentication successful
+- `401 Unauthorized` — Invalid credentials
+- `403 Forbidden` — Account disabled
+- `422 Unprocessable Entity` — Invalid request format
+
+### Using the Token
+
+Include the JWT token in the `Authorization` header with the `Bearer` scheme:
+
+**HTTP Header:**
+
+```
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+**Example HTTP Request:**
+
+```http
+GET /api/v1/boards HTTP/1.1
+Host: localhost:9090
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+Content-Type: application/json
+```
+
+### Token Expiration
+
+Tokens have a limited lifetime:
+
+- **Default**: 24 hours (86400 seconds)
+- **Remember Me**: 7 days (604800 seconds)
+
+**Handling Expiration:**
+
+```python
+import requests
+import time
+
+def api_request(url, token, max_retries=1):
+ headers = {"Authorization": f"Bearer {token}"}
+ response = requests.get(url, headers=headers)
+
+ if response.status_code == 401: # Token expired
+ # Re-authenticate and retry
+ new_token = login()
+ headers = {"Authorization": f"Bearer {new_token}"}
+ response = requests.get(url, headers=headers)
+
+ return response
+```
+
+### Logout Endpoint
+
+**Endpoint:** `POST /api/v1/auth/logout`
+
+**Request:**
+
+```http
+POST /api/v1/auth/logout HTTP/1.1
+Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+```
+
+**Response:**
+
+```json
+{
+ "success": true
+}
+```
+
+**Note:** With JWT tokens, logout is primarily client-side (delete token). Server-side session invalidation may be added in future releases.
+
+## Code Examples
+
+### Python
+
+**Using `requests` library:**
+
+```python
+import requests
+import json
+
+class InvokeAIClient:
+ def __init__(self, base_url="http://localhost:9090"):
+ self.base_url = base_url
+ self.token = None
+
+ def login(self, email, password, remember_me=False):
+ """Authenticate and store token."""
+ url = f"{self.base_url}/api/v1/auth/login"
+ payload = {
+ "email": email,
+ "password": password,
+ "remember_me": remember_me
+ }
+
+ response = requests.post(url, json=payload)
+ response.raise_for_status()
+
+ data = response.json()
+ self.token = data["token"]
+ return data["user"]
+
+ def _get_headers(self):
+ """Get headers with authentication token."""
+ if not self.token:
+ raise Exception("Not authenticated. Call login() first.")
+
+ return {
+ "Authorization": f"Bearer {self.token}",
+ "Content-Type": "application/json"
+ }
+
+ def get_boards(self):
+ """Get user's boards."""
+ url = f"{self.base_url}/api/v1/boards/"
+ response = requests.get(url, headers=self._get_headers())
+ response.raise_for_status()
+ return response.json()
+
+ def create_board(self, board_name):
+ """Create a new board."""
+ url = f"{self.base_url}/api/v1/boards/"
+ response = requests.post(
+ url,
+ params={"board_name": board_name},
+ headers=self._get_headers()
+ )
+ response.raise_for_status()
+ return response.json()
+
+ def logout(self):
+ """Logout and clear token."""
+ url = f"{self.base_url}/api/v1/auth/logout"
+ response = requests.post(url, headers=self._get_headers())
+ self.token = None
+ return response.json()
+
+# Usage
+client = InvokeAIClient()
+user = client.login("user@example.com", "SecurePassword123")
+print(f"Logged in as: {user['display_name']}")
+
+boards = client.get_boards()
+print(f"User has {len(boards['items'])} boards")
+
+new_board = client.create_board("My New Board")
+print(f"Created board: {new_board['board_name']}")
+
+client.logout()
+```
+
+**Error Handling:**
+
+```python
+import requests
+from requests.exceptions import HTTPError
+
+def safe_api_call(client, method, *args, **kwargs):
+ """Make API call with error handling."""
+ try:
+ func = getattr(client, method)
+ return func(*args, **kwargs)
+
+ except HTTPError as e:
+ if e.response.status_code == 401:
+ print("Authentication failed or token expired")
+ # Re-authenticate
+ client.login(email, password)
+ # Retry
+ return func(*args, **kwargs)
+ elif e.response.status_code == 403:
+ print("Permission denied")
+ elif e.response.status_code == 404:
+ print("Resource not found")
+ else:
+ print(f"API error: {e.response.status_code}")
+ print(e.response.text)
+
+ raise
+
+# Usage
+try:
+ boards = safe_api_call(client, "get_boards")
+except Exception as e:
+ print(f"Failed to get boards: {e}")
+```
+
+### JavaScript/TypeScript
+
+**Using `fetch` API:**
+
+```javascript
+class InvokeAIClient {
+ constructor(baseUrl = 'http://localhost:9090') {
+ this.baseUrl = baseUrl;
+ this.token = null;
+ }
+
+ async login(email, password, rememberMe = false) {
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email,
+ password,
+ remember_me: rememberMe,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Login failed: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ this.token = data.token;
+
+ // Store token in localStorage
+ localStorage.setItem('invokeai_token', data.token);
+
+ return data.user;
+ }
+
+ getHeaders() {
+ if (!this.token) {
+ throw new Error('Not authenticated. Call login() first.');
+ }
+
+ return {
+ 'Authorization': `Bearer ${this.token}`,
+ 'Content-Type': 'application/json',
+ };
+ }
+
+ async getBoards() {
+ const response = await fetch(`${this.baseUrl}/api/v1/boards/`, {
+ headers: this.getHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to get boards: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+
+ async createBoard(boardName) {
+ const url = new URL(`${this.baseUrl}/api/v1/boards/`);
+ url.searchParams.set('board_name', boardName);
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create board: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+
+ async logout() {
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/logout`, {
+ method: 'POST',
+ headers: this.getHeaders(),
+ });
+
+ this.token = null;
+ localStorage.removeItem('invokeai_token');
+
+ return response.json();
+ }
+}
+
+// Usage
+(async () => {
+ const client = new InvokeAIClient();
+
+ try {
+ const user = await client.login('user@example.com', 'SecurePassword123');
+ console.log(`Logged in as: ${user.display_name}`);
+
+ const boards = await client.getBoards();
+ console.log(`User has ${boards.items.length} boards`);
+
+ const newBoard = await client.createBoard('My New Board');
+ console.log(`Created board: ${newBoard.board_name}`);
+
+ await client.logout();
+ } catch (error) {
+ console.error('Error:', error.message);
+ }
+})();
+```
+
+**TypeScript with Types:**
+
+```typescript
+interface LoginRequest {
+ email: string;
+ password: string;
+ remember_me?: boolean;
+}
+
+interface User {
+ user_id: string;
+ email: string;
+ display_name: string;
+ is_admin: boolean;
+ is_active: boolean;
+ created_at: string;
+}
+
+interface LoginResponse {
+ token: string;
+ user: User;
+ expires_in: number;
+}
+
+interface Board {
+ board_id: string;
+ board_name: string;
+ created_at: string;
+ updated_at: string;
+ deleted_at?: string;
+ cover_image_name?: string;
+}
+
+class InvokeAIClient {
+ private baseUrl: string;
+ private token: string | null = null;
+
+ constructor(baseUrl: string = 'http://localhost:9090') {
+ this.baseUrl = baseUrl;
+ }
+
+ async login(
+ email: string,
+ password: string,
+ rememberMe: boolean = false
+ ): Promise {
+ const response = await fetch(`${this.baseUrl}/api/v1/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password, remember_me: rememberMe }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || 'Login failed');
+ }
+
+ const data: LoginResponse = await response.json();
+ this.token = data.token;
+ return data.user;
+ }
+
+ private getHeaders(): HeadersInit {
+ if (!this.token) {
+ throw new Error('Not authenticated');
+ }
+ return {
+ 'Authorization': `Bearer ${this.token}`,
+ 'Content-Type': 'application/json',
+ };
+ }
+
+ async getBoards(): Promise<{ items: Board[] }> {
+ const response = await fetch(`${this.baseUrl}/api/v1/boards/`, {
+ headers: this.getHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to get boards');
+ }
+
+ return response.json();
+ }
+}
+```
+
+### cURL
+
+**Login:**
+
+```bash
+# Login and extract token
+TOKEN=$(curl -X POST http://localhost:9090/api/v1/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{
+ "email": "user@example.com",
+ "password": "SecurePassword123",
+ "remember_me": false
+ }' | jq -r '.token')
+
+echo "Token: $TOKEN"
+```
+
+**Get Boards:**
+
+```bash
+curl -X GET http://localhost:9090/api/v1/boards/ \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json"
+```
+
+**Create Board:**
+
+```bash
+curl -X POST "http://localhost:9090/api/v1/boards/?board_name=My%20API%20Board" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+## API Endpoint Changes
+
+### Authentication Required
+
+All endpoints now require authentication except:
+
+- `GET /api/v1/auth/status` — Check whether multi-user setup is required
+- `POST /api/v1/auth/setup` — Initial admin setup
+- `POST /api/v1/auth/login` — User login
+- `GET /api/v1/images/i/{image_name}/full` — Full-resolution image file
+- `GET /api/v1/images/i/{image_name}/thumbnail` — Image thumbnail
+- `GET /api/v1/workflows/i/{workflow_id}/thumbnail` — Workflow thumbnail
+
+The image and thumbnail endpoints are intentionally unauthenticated because browsers load these resources via ` ` tags, which cannot send `Authorization` headers. Security relies on the fact that image and workflow IDs are UUIDs and therefore unguessable.
+
+### User-Scoped Resources
+
+Resources are now filtered by the authenticated user:
+
+**Boards:**
+
+```python
+# Before (single-user)
+GET /api/v1/boards/?all=true # Returns all boards
+
+# After (multi-user)
+GET /api/v1/boards/?all=true # Returns boards the current user can access, including their own boards plus shared/public boards; admins can see all boards
+```
+
+**Images:**
+
+```python
+# Images are filtered by board ownership
+GET /api/v1/images/ # Only shows images on user's boards
+```
+
+**Workflows:**
+
+```python
+# Returns user's workflows + public workflows
+GET /api/v1/workflows/
+```
+
+**Queue:**
+
+```python
+# Regular users see their own queue items in full and may see redacted details for other users' items on queue-status endpoints
+GET /api/v1/queue/... # Queue data, sanitized for non-admin viewers
+
+# Administrators see all queue items in full
+GET /api/v1/queue/... # Full queue data
+```
+
+### Administrator Endpoints
+
+Some endpoints require administrator privileges:
+
+**User Management:**
+
+```python
+GET /api/v1/auth/users # List users (admin only)
+POST /api/v1/auth/users # Create user (admin only)
+GET /api/v1/auth/users/{id} # Get user (admin only)
+PATCH /api/v1/auth/users/{id} # Update user (admin only)
+DELETE /api/v1/auth/users/{id} # Delete user (admin only)
+```
+
+**Model Management (Write Operations):**
+
+```python
+POST /api/v2/models/install # Install model (admin only)
+DELETE /api/v2/models/i/{key} # Delete model (admin only)
+PATCH /api/v2/models/i/{key} # Update model (admin only)
+PUT /api/v2/models/convert/{key} # Convert model (admin only)
+```
+
+**Model Management (Read Operations):**
+
+```python
+GET /api/v2/models/ # List models (all users)
+GET /api/v2/models/i/{key} # Get model details (all users)
+```
+
+### Error Responses
+
+**401 Unauthorized:**
+
+```json
+{
+ "detail": "Invalid authentication credentials"
+}
+```
+
+Occurs when:
+
+- Token is missing
+- Token is invalid
+- Token is expired
+- Token signature is invalid
+
+**403 Forbidden:**
+
+```json
+{
+ "detail": "Admin privileges required"
+}
+```
+
+Occurs when:
+
+- User attempts admin-only operation
+- Account is disabled
+- Insufficient permissions
+
+**404 Not Found:**
+
+```json
+{
+ "detail": "Resource not found"
+}
+```
+
+Occurs when:
+
+- Resource doesn't exist
+- User doesn't have access to resource
+
+## Multiuser API Endpoints
+
+### Authentication Endpoints
+
+#### Check if initial administrator setup is required
+
+**Endpoint:** `GET /api/v1/auth/status`
+
+**Description:** Returns a SetupStatusResponse indicating whether setup is needed and multiuser mode status.
+
+**Request:** No parameters
+
+**Response (initial setup not yet complete):**
+```json
+{
+ "setup_required": true,
+ "multiuser_enabled": true,
+ "strict_password_checking": true,
+ "admin_email": "admin@example.com"
+}
+```
+
+**Response (setup already complete, or multiuser disabled):**
+```json
+{
+ "setup_required": false,
+ "multiuser_enabled": true,
+ "strict_password_checking": true,
+ "admin_email": null
+}
+```
+
+:::note
+`admin_email` is only populated while `setup_required` is `true` (to help locate the pre-seeded
+administrator account during initial setup). Once an admin has been created — and whenever
+multiuser mode is disabled — it is returned as `null` to avoid leaking administrator identity on
+public deployments.
+:::
+
+
+#### Setup Administrator
+
+**Endpoint:** `POST /api/v1/auth/setup`
+
+**Description:** Create initial administrator account (only works if no admin exists)
+
+**Request:**
+
+```json
+{
+ "email": "admin@example.com",
+ "display_name": "Administrator",
+ "password": "SecureAdminPass123"
+}
+```
+
+**Response:**
+
+```json
+{
+ "success": true,
+ "user": {
+ "user_id": "abc123",
+ "email": "admin@example.com",
+ "display_name": "Administrator",
+ "is_admin": true,
+ "is_active": true
+ }
+}
+```
+
+
+#### Get Current User
+
+**Endpoint:** `GET /api/v1/auth/me`
+
+**Description:** Get currently authenticated user's information
+
+**Request:**
+
+```http
+GET /api/v1/auth/me
+Authorization: Bearer
+```
+
+**Response:**
+
+```json
+{
+ "user_id": "abc123",
+ "email": "user@example.com",
+ "display_name": "John Doe",
+ "is_admin": false,
+ "is_active": true,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-01-15T10:00:00Z",
+ "last_login_at": "2024-01-15T15:30:00Z"
+}
+```
+
+### User Management Endpoints (Admin Only)
+
+#### List Users
+
+**Endpoint:** `GET /api/v1/auth/users`
+
+**Request:**
+
+```http
+GET /api/v1/auth/users
+Authorization: Bearer
+```
+
+**Response:**
+
+```json
+[
+ {
+ "user_id": "abc123",
+ "email": "user@example.com",
+ "display_name": "John Doe",
+ "is_admin": false,
+ "is_active": true,
+ "created_at": "2024-01-15T10:00:00Z",
+ "updated_at": "2024-04-25T17:23:00Z",
+ "last_login_at": "2024-01-15T15:30:00Z"
+ }
+]
+```
+
+#### Create User
+
+**Endpoint:** `POST /api/v1/auth/users`
+
+**Request:**
+
+```json
+{
+ "email": "newuser@example.com",
+ "display_name": "New User",
+ "password": "TempPassword123",
+ "is_admin": false
+}
+```
+
+**Response:**
+
+```json
+{
+ "user_id": "xyz789",
+ "email": "newuser@example.com",
+ "display_name": "New User",
+ "is_admin": false,
+ "is_active": true,
+ "created_at": "2024-01-15T16:00:00Z"
+}
+```
+
+#### Update User
+
+**Endpoint:** `PATCH /api/v1/auth/users/{user_id}`
+
+**Request:**
+
+```json
+{
+ "display_name": "Updated Name",
+ "is_active": true,
+ "is_admin": false
+}
+```
+
+**Response:**
+
+```json
+{
+ "user_id": "xyz789",
+ "email": "newuser@example.com",
+ "display_name": "Updated Name",
+ "is_admin": false,
+ "is_active": true
+}
+```
+
+#### Delete User
+
+**Endpoint:** `DELETE /api/v1/auth/users/{user_id}`
+
+**Response:**
+
+Returns `204 No Content` on success.
+
+On an error, it returns `422 Unprocessable Content` and the following JSON:
+
+```json
+{
+ "detail": [
+ {
+ "loc": [
+ "string",
+ 0
+ ],
+ "msg": "string",
+ "type": "string"
+ }
+ ]
+}
+```
+
+#### List Image Boards
+
+**Endpoint:** `GET /api/v1/boards/`
+
+**Response:**
+
+```json
+{
+ "limit": 0,
+ "offset": 0,
+ "total": 0,
+ "items": [
+ {
+ "board_id": "8b31a33d-0acb-46fe-8612-83601481cf2c",
+ "board_name": "Testing Board",
+ "user_id": "string",
+ "created_at": "2026-05-07T03:04:00.738Z",
+ "updated_at": "2026-05-07T03:04:00.738Z",
+ "deleted_at": "2026-05-07T03:04:00.738Z",
+ "cover_image_name": "string",
+ "archived": false,
+ "board_visibility": "private",
+ "image_count": 0,
+ "asset_count": 0,
+ "owner_username": "string"
+ }
+ ]
+}
+```
+
+This returns a paged response. See the swagger page (`http://localhost:9090/docs#/boards/list_boards`) for details.
+The `board_visibility` field will be one of:
+
+- `private` -- private to the owner and administrator
+- `shared` -- read/write to the owner and administrator, read-only to everyone else
+- `public` -- read/write by everyone
+
+#### Get One Board
+
+**Endpoint:** `GET /api/v1/boards/{board_id}`
+
+**Response:**
+
+```json
+{
+ "board_id": "8b31a33d-0acb-46fe-8612-83601481cf2c",
+ "board_name": "Testing Board",
+ "user_id": "3c59a0ba-f4c7-4275-b96f-82179e8aaff8",
+ "created_at": "2026-03-09 16:10:47.095",
+ "updated_at": "2026-03-09 16:10:55",
+ "deleted_at": null,
+ "cover_image_name": "08689e4b-f084-4c49-83a8-4fc1edb167c4.png",
+ "archived": false,
+ "board_visibility": "shared",
+ "image_count": 55,
+ "asset_count": 0,
+ "owner_username": null
+}
+```
+
+
+## Best Practices
+
+### Token Storage
+
+**Do:**
+
+- Store tokens securely (keychain, secure storage)
+- Use HTTPS to transmit tokens
+- Clear tokens on logout
+- Handle token expiration gracefully
+
+**Don't:**
+
+- Store tokens in URL parameters
+- Log tokens in plain text
+- Share tokens between users
+- Store tokens in version control
+
+### Error Handling
+
+Always handle authentication errors:
+
+```python
+def make_request(client, func, *args, **kwargs):
+ max_retries = 3
+ retry_count = 0
+
+ while retry_count < max_retries:
+ try:
+ return func(*args, **kwargs)
+ except AuthenticationError:
+ if retry_count >= max_retries - 1:
+ raise
+ # Re-authenticate
+ client.login(email, password)
+ retry_count += 1
+ except Exception as e:
+ logger.error(f"Request failed: {e}")
+ raise
+```
+
+### Rate Limiting
+
+Be mindful of API rate limits:
+
+- Implement exponential backoff for retries
+- Cache frequently accessed data
+- Batch requests when possible
+- Don't hammer the login endpoint
+
+### Connection Management
+
+```python
+import requests
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+def create_session():
+ """Create session with retry logic."""
+ session = requests.Session()
+
+ retry = Retry(
+ total=3,
+ backoff_factor=0.3,
+ status_forcelist=[500, 502, 503, 504],
+ )
+
+ adapter = HTTPAdapter(max_retries=retry)
+ session.mount('http://', adapter)
+ session.mount('https://', adapter)
+
+ return session
+```
+
+## Migration Guide
+
+### Updating Existing Code
+
+**Before (single-user mode):**
+
+```python
+import requests
+
+def get_boards():
+ response = requests.get("http://localhost:9090/api/v1/boards/")
+ return response.json()
+```
+
+**After (multi-user mode):**
+
+```python
+import requests
+
+class APIClient:
+ def __init__(self):
+ self.token = None
+
+ def login(self, email, password):
+ response = requests.post(
+ "http://localhost:9090/api/v1/auth/login",
+ json={"email": email, "password": password}
+ )
+ self.token = response.json()["token"]
+
+ def get_boards(self):
+ headers = {"Authorization": f"Bearer {self.token}"}
+ response = requests.get(
+ "http://localhost:9090/api/v1/boards/",
+ headers=headers
+ )
+ return response.json()
+
+# Usage
+client = APIClient()
+client.login("user@example.com", "password")
+boards = client.get_boards()
+```
+
+### Backward Compatibility
+
+InvokeAI supports both single-user and multi-user modes via the `multiuser` configuration option.
+
+**Configuration:**
+
+```yaml
+# invokeai.yaml
+
+# Single-user mode (no authentication)
+multiuser: false # or omit the option entirely
+
+# Multi-user mode (authentication required)
+multiuser: true
+```
+
+**Checking Mode Programmatically:**
+
+```python
+def is_multiuser_enabled(base_url):
+ response = requests.get(f"{base_url}/api/v1/auth/status")
+ response.raise_for_status()
+ return response.json()["multiuser_enabled"]
+
+# Example usage
+base_url = "http://localhost:9090"
+if is_multiuser_enabled(base_url):
+ print("Multi-user mode: authentication required")
+ # Use authenticated API calls
+else:
+ print("Single-user mode: no authentication needed")
+ # Use direct API calls
+```
+
+**Adaptive Client:**
+
+```python
+class AdaptiveInvokeAIClient:
+ def __init__(self, base_url="http://localhost:9090"):
+ self.base_url = base_url
+ self.token = None
+ self.multiuser_mode = self._check_multiuser_mode()
+
+ def _check_multiuser_mode(self):
+ """Detect if multi-user mode is enabled."""
+ try:
+ response = requests.get(f"{self.base_url}/api/v1/boards/")
+ return response.status_code == 401
+ except:
+ return False
+
+ def login(self, email, password):
+ """Login (only needed in multi-user mode)."""
+ if not self.multiuser_mode:
+ print("Single-user mode: login not required")
+ return
+
+ response = requests.post(
+ f"{self.base_url}/api/v1/auth/login",
+ json={"email": email, "password": password}
+ )
+ self.token = response.json()["token"]
+
+ def _get_headers(self):
+ """Get headers (with auth token if in multi-user mode)."""
+ if self.multiuser_mode and self.token:
+ return {"Authorization": f"Bearer {self.token}"}
+ return {}
+
+ def get_boards(self):
+ """Get boards (works in both modes)."""
+ response = requests.get(
+ f"{self.base_url}/api/v1/boards/",
+ headers=self._get_headers()
+ )
+ return response.json()
+```
+
+## Programmatic Graph Execution
+
+If you are building an external client against the HTTP API, see the dedicated [Workflow Execution guide](/development/guides/workflow-api/).
+
+In multi-user mode, remember to include your `Authorization: Bearer ` header on queue and workflow requests.
+
+## OpenAPI/Swagger Documentation
+
+InvokeAI provides OpenAPI documentation for all endpoints.
+
+**Access Swagger UI:**
+
+```
+http://localhost:9090/docs
+```
+
+**Download OpenAPI Schema:**
+
+```bash
+curl http://localhost:9090/openapi.json > invokeai_openapi.json
+```
+
+**Generate Client Code:**
+
+Use tools like `openapi-generator` to generate client libraries:
+
+```bash
+# Generate Python client
+openapi-generator generate \
+ -i http://localhost:9090/openapi.json \
+ -g python \
+ -o ./invokeai-client
+
+# Generate TypeScript client
+openapi-generator generate \
+ -i http://localhost:9090/openapi.json \
+ -g typescript-fetch \
+ -o ./invokeai-client-ts
+```
+
+## Security Considerations
+
+### HTTPS
+
+Always use HTTPS in production:
+
+```python
+# Development
+client = InvokeAIClient("http://localhost:9090")
+
+# Production
+client = InvokeAIClient("https://invoke.example.com")
+```
+
+### Token Security
+
+Protect JWT tokens:
+
+```python
+# Never log tokens
+logger.info(f"User logged in") # Good
+logger.info(f"Token: {token}") # Bad!
+
+# Use environment variables for credentials
+import os
+email = os.environ.get("INVOKEAI_EMAIL")
+password = os.environ.get("INVOKEAI_PASSWORD")
+```
+
+### Input Validation
+
+Always validate user input:
+
+```python
+import re
+
+def validate_email(email):
+ pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
+ return re.match(pattern, email) is not None
+
+def validate_password(password):
+ """Check password meets requirements."""
+ if len(password) < 8:
+ return False, "Password must be at least 8 characters"
+ if not any(c.isupper() for c in password):
+ return False, "Password must contain uppercase letters"
+ if not any(c.islower() for c in password):
+ return False, "Password must contain lowercase letters"
+ if not any(c.isdigit() for c in password):
+ return False, "Password must contain numbers"
+ return True, ""
+```
+
+## Troubleshooting
+
+### Common Issues
+
+**Issue: "Invalid authentication credentials"**
+
+- Token expired — re-authenticate
+- Token malformed — check token string
+- Token signature invalid — check secret key hasn't changed
+
+**Issue: "Admin privileges required"**
+
+- User is not an administrator
+- Use admin account for this operation
+
+**Issue: Token not being sent**
+
+- Check `Authorization` header is present
+- Verify `Bearer` prefix is included
+- Check token isn't truncated
+
+**Issue: CORS errors**
+
+Configure CORS in InvokeAI:
+
+```yaml
+# invokeai.yaml
+allow_origins:
+ - "http://localhost:3000"
+ - "https://myapp.example.com"
+allow_credentials: true
+allow_methods:
+ - "*"
+allow_headers:
+ - "*"
+```
+
+## Additional Resources
+
+- [User Guide](./user-guide/) — For end users
+- [Administrator Guide](./admin-guide/) — For administrators
+- [GitHub Repository](https://github.com/invoke-ai/InvokeAI) — Source code
+
+---
+
+**Questions?** Visit the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy) or check the [FAQ](/troubleshooting/faq/).
diff --git a/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-1.png b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-1.png
new file mode 100644
index 00000000000..706039d50cb
Binary files /dev/null and b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-1.png differ
diff --git a/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-2.png b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-2.png
new file mode 100644
index 00000000000..44bf2e180a3
Binary files /dev/null and b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-2.png differ
diff --git a/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-3.png b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-3.png
new file mode 100644
index 00000000000..708f9a85135
Binary files /dev/null and b/docs/src/content/docs/features/Multi-User Mode/assets/admin-add-user-3.png differ
diff --git a/docs/src/content/docs/features/Multi-User Mode/assets/admin-setup.png b/docs/src/content/docs/features/Multi-User Mode/assets/admin-setup.png
new file mode 100644
index 00000000000..fbe035e5c6e
Binary files /dev/null and b/docs/src/content/docs/features/Multi-User Mode/assets/admin-setup.png differ
diff --git a/docs/src/content/docs/features/Multi-User Mode/assets/user-login-1.png b/docs/src/content/docs/features/Multi-User Mode/assets/user-login-1.png
new file mode 100644
index 00000000000..8c4bec22943
Binary files /dev/null and b/docs/src/content/docs/features/Multi-User Mode/assets/user-login-1.png differ
diff --git a/docs/src/content/docs/features/Multi-User Mode/user-guide.mdx b/docs/src/content/docs/features/Multi-User Mode/user-guide.mdx
new file mode 100644
index 00000000000..d595668a65e
--- /dev/null
+++ b/docs/src/content/docs/features/Multi-User Mode/user-guide.mdx
@@ -0,0 +1,366 @@
+---
+title: Multi-User Guide
+description: How to use InvokeAI in multi-user mode as an end user.
+sidebar:
+ order: 3
+---
+import { Steps } from '@astrojs/starlight/components'
+
+## Overview
+
+Multi-User mode is a recent feature (introduced in version 6.12), which allows multiple individuals to share a single InvokeAI server while keeping their work separate and organized. Each user has their own username and login password, images, assets, image boards, customization settings and workflows.
+
+Two types of users are recognized:
+
+- A user with **Administrator** status can add, remove and modify other users, and can install models. They also have the ability to view the full session queue and pause or kill other users' jobs.
+- **Non-administrator** users can modify their own profile but not others. They also do not have the ability to install or configure models, but must ask an Administrator to do this task. When viewing the generation queue, they can see the full details of their own jobs, but jobs owned by other users will have the user id, generation parameters, and other details redacted.
+
+Multiple users can be granted Administrator status.
+
+---
+
+## Getting Started
+
+To activate Multi-User mode, open the `INVOKEAI_ROOT/invokeai.yaml` configuration file in a text editor. Add this line anywhere in the file:
+
+```yaml
+multiuser: true
+```
+
+You may also wish to make InvokeAI available to other machines on your local LAN. Add an additional line to `invokeai.yaml`:
+
+```yaml
+host: 0.0.0.0
+```
+
+Restart the server. It will now be in multi-user mode. If you enabled the `host` option, other users on your home or office LAN will be able to reach it by browsing to the IP address of the machine the backend is running on (`http://host-ip-address:9090`).
+
+:::tip[Do not expose InvokeAI to the internet]
+It is not recommended to expose the InvokeAI host to the internet due to security concerns.
+:::
+
+### Initial Setup (First Time in Multi-User Mode)
+
+If you're the first person to access a fresh InvokeAI installation in multi-user mode, you'll see the **Administrator Setup** dialog:
+
+
+
+Now:
+
+
+1. Enter your email address (this will be your login name)
+2. Create a display name (this will be the name other users see)
+3. Choose a strong password. The following criteria are required with `strict_password_checking: true`.
+ - At least 8 characters long
+ - Contains uppercase letters
+ - Contains lowercase letters
+ - Contains numbers
+4. Confirm your password
+5. Click **Create Administrator Account**
+
+
+With `strict_password_checking` disabled, you'll be warned if you choose a
+weak password, but not prevented from doing so.
+
+You'll now be taken to a login screen and can enter the credentials you just created.
+
+### Adding and Modifying Users
+
+If you are logged in as Administrator, you can add additional users. Click on the small "person silhouette" icon at the bottom left of the main Invoke screen and select "User Management"
+
+
+
+This will take you to the User Management screen...
+
+
+
+...where you can click "Create User" to add a new user.
+
+
+
+The User Management screen also allows you to:
+
+
+1. Temporarily change a user's status to Inactive, preventing them from logging in to Invoke.
+2. Edit a user (by clicking on the pencil icon) to change the user's display name or password.
+3. Permanently delete a user.
+4. Grant a user Administrator privileges.
+
+
+---
+
+## Logging in as a Non-Administrative User
+
+If you are a registered user on the system, enter your email address and password to log in. The Administrator will be able to provide you with the values to use:
+
+
+
+As an unprivileged user you can do pretty much anything that's allowed under single-user mode — generating images, using LoRAs, creating and running workflows, creating image boards — but you are restricted against installing new models, changing low-level server settings, or interfering with other users. More information on user roles is given below.
+
+### Changing your Profile
+
+To change your display name or profile, click on the person silhouette icon at the bottom left of the screen and choose "My Profile". This will take you to a screen that lets you change these values. At this time you can change your display name but not your login ID (ordinarily your contact email address).
+
+---
+
+## Understanding User Roles
+
+In single-user mode, you have access to all features without restrictions. In multi-user mode, InvokeAI has two user roles:
+
+### Regular User
+
+As a regular user, you can:
+
+- Create and manage your own image boards
+- Generate images using all AI tools (Linear, Canvas, Upscale, Workflows)
+- Create, save, and load your own workflows
+- View the full details of jobs you own on the session queue
+- View redacted information for jobs being run by other users
+- Customize your UI preferences (theme, hotkeys, etc.)
+- View available models (read-only access to Model Manager)
+- View shared and public boards created by other users
+- View and use workflows marked as shared by other users
+
+You cannot:
+
+- Add, delete, or modify models
+- View or modify other users' private boards, images, or workflows
+- Manage user accounts
+- Access system configuration
+- Cancel other users' generation jobs
+
+:::tip[The generation queue]
+When two or more users are accessing InvokeAI at the same time, their image generation jobs will be placed on the session queue on a first-come, first-serve basis. This means that you will have to wait for other users' image rendering jobs to complete before yours will start.
+
+While other users' jobs are running you will see the shared image generation progress bar, and the queue badge will show a single number — the count of your own jobs that are pending or in progress. It does not show other users' counts.
+
+Open the Queue tab to see where your job sits in relation to the other queued tasks.
+:::
+
+### Administrator
+
+Administrators have all regular user capabilities, plus:
+
+- Full model management (add, delete, configure models)
+- Create and manage user accounts
+- View and manage all users' generation queues
+- View and manage all users' boards, images, and workflows (including system-owned legacy content)
+- Access system configuration
+- Grant or revoke admin privileges
+
+---
+
+## Working with Your Content in Multi-User Mode
+
+### Image Boards
+
+In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels:
+
+- **Private** (default): Only you (and administrators) can see and modify the board.
+- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images).
+- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete).
+
+To change a board's visibility, right-click on the board and select the desired visibility option.
+
+Administrators can see and manage all users' image boards and their contents regardless of visibility settings.
+
+### Going From Multi-User to Single-User Mode
+
+If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be assigned to the internal 'system' user. Admins can access this legacy content, and will not be restored to original owners.
+
+### Workflows
+
+Each user has their own private workflow library. Workflows you create are visible only to you by default.
+
+You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them.
+
+To share a workflow, open it and use the sharing controls to toggle its public/shared status.
+
+:::caution[Preexisting workflows after enabling multi-user mode]
+When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them.
+:::
+
+### The Generation Queue
+
+The queue shows your pending and running generation tasks.
+
+**Queue Features:**
+
+- View your current and completed generations
+- Cancel pending tasks
+- Re-run previous generations
+- Monitor progress in real-time
+
+**Queue Isolation:**
+
+- You will see your own queue items, as well as the items generated by other users, but the generation parameters (e.g. prompts) for other users' jobs are hidden for privacy reasons.
+- Administrators can view all queues for troubleshooting.
+- Your generations won't interfere with other users' tasks.
+
+---
+
+## Customizing Your Experience
+
+### Personal Preferences
+
+Your UI preferences are saved to your account and are restored when you log in:
+
+- **Hotkeys**: Customize keyboard shortcuts
+- **Canvas Settings**: Default zoom, grid visibility, etc.
+- **Generation Defaults**: Default values for width, height, steps, etc.
+
+These settings are stored per-user and won't affect other users.
+
+---
+
+## Troubleshooting
+
+### Cannot Log In
+
+**Issue:** Login fails with "Incorrect email or password"
+
+**Solutions:**
+
+- Verify you're entering the correct email address
+- Check that Caps Lock is off
+- Try typing the password slowly to avoid mistakes
+- Contact your administrator if you've forgotten your password
+
+**Issue:** Login fails with "Account is disabled"
+
+**Solution:** Contact your administrator to reactivate your account
+
+### Session Expired
+
+**Issue:** You're suddenly logged out and see "Session expired"
+
+**Explanation:** Sessions expire after 24 hours (or 7 days with "remember me")
+
+**Solution:** Simply log in again with your credentials
+
+### Cannot Access Features
+
+**Issue:** Features like Model Manager show "Admin privileges required"
+
+**Explanation:** Some features are restricted to administrators
+
+**Solution:**
+
+- For model viewing: You can view but not modify models
+- For user management: Contact an administrator
+- For system configuration: Contact an administrator
+
+### Missing Boards or Images
+
+**Issue:** Boards or images you created are not visible
+
+**Possible Causes:**
+
+
+1. **Filter Applied:** Check if a filter is hiding content
+2. **Wrong User:** Ensure you're logged in with the correct account
+3. **Archived Board:** Check the "Show Archived" option
+
+
+**Solution:**
+
+- Clear any active filters
+- Verify you're logged in as the right user
+- Check archived items
+
+### Slow Performance
+
+**Issue:** Generation or UI feels slower than expected
+
+**Possible Causes:**
+
+- Other users generating images simultaneously
+- Server resource limits
+- Network latency
+
+**Solutions:**
+
+- Check the queue to see if others are generating
+- Wait for current generations to complete
+- Contact administrator if persistent
+
+### Generation Stuck in Queue
+
+**Issue:** Your generation is queued but not starting
+
+**Possible Causes:**
+
+- Server is processing other users' generations
+- Server resources are fully utilized
+- Technical issue with the server
+
+**Solutions:**
+
+- Wait for your turn in the queue
+- Check if your generation is paused
+- Contact administrator if stuck for extended period
+
+---
+
+## Frequently Asked Questions
+
+### Can other users see my images?
+
+Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default.
+
+### Can I share my workflows with others?
+
+Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them.
+
+### How long do sessions last?
+
+- 24 hours by default
+- 7 days if you check "Remember me" during login
+
+### Can I use the API with multi-user mode?
+
+Yes, but you'll need to authenticate with a JWT token. See the [API Guide](./api-guide/) for details.
+
+### What happens if I forget my password?
+
+Contact your administrator. They can reset your password for you.
+
+### Can I have multiple sessions?
+
+Yes, you can log in from multiple devices or browsers simultaneously. All sessions will use the same account and see the same content.
+
+### Why can't I see the Model Manager "Add Models" tab?
+
+Regular users can see the Models tab but with read-only access. Check that you're logged in and try refreshing the page.
+
+### How do I know if I'm an administrator?
+
+Click the user icon near the bottom of the left-hand navigation bar to open the user menu. If you are an administrator, an "Admin" badge appears under your name in that menu and a "User Management" item is shown alongside the usual Profile and Logout actions.
+
+### Can I request admin privileges?
+
+Yes, ask your current administrator to grant you admin privileges. Admin privileges will give you the ability to see all other users' boards and images, as well as to add models and change various server-wide settings.
+
+## Getting Help
+
+### Support Channels
+
+- **Administrator:** Contact your system administrator for account issues
+- **Documentation:** Check the [FAQ](/troubleshooting/faq/) for common issues
+- **Community:** Join the [Discord](https://discord.gg/ZmtBAhwWhy) for help
+- **Bug Reports:** File issues on [GitHub](https://github.com/invoke-ai/InvokeAI/issues)
+
+### Reporting Issues
+
+When reporting an issue, include:
+
+- Your role (regular user or administrator)
+- What you were trying to do
+- What happened instead
+- Any error messages you saw
+- Your browser and operating system
+
+## Additional Resources
+
+- [Administrator Guide](./admin-guide/) — For administrators managing users and the system
+- [API Guide](./api-guide/) — For developers using the InvokeAI API
diff --git a/docs/src/content/docs/features/Workflows/adding-nodes.mdx b/docs/src/content/docs/features/Workflows/adding-nodes.mdx
new file mode 100644
index 00000000000..6210d739194
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/adding-nodes.mdx
@@ -0,0 +1,170 @@
+---
+title: Adding Nodes
+description: Learn how to add, connect, and configure nodes in InvokeAI's workflow editor.
+sidebar:
+ order: 3
+lastUpdated: 2026-03-16
+---
+
+import { Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Nodes are the building blocks of workflows. Each node performs a specific operation — loading a model, generating noise, applying conditioning, denoising latents, and more. By adding nodes to the canvas and connecting them together, you create a complete image generation pipeline.
+
+## Opening the Node Picker
+
+The node picker is a searchable command palette that lists every available node. There are three ways to open it:
+
+
+
+ Press Shift + A or Space while the workflow editor is focused.
+
+
+ Click the **+** button in the top-left corner of the canvas.
+
+
+ Drag a connection from any input or output port and release it over empty canvas. The picker will open with results **filtered to compatible nodes only**.
+
+
+
+## Finding a Node
+
+When the node picker opens, you can immediately start typing to search. The search is fuzzy and matches against several properties of each node:
+
+- **Title** — the display name (e.g. "Denoise Latents")
+- **Type** — the internal identifier
+- **Description** — a short summary of what the node does
+- **Tags** — category keywords
+- **Node Pack** — the origin module (e.g. `invokeai` for built-in nodes, or a community pack name)
+
+Each entry in the picker shows:
+
+- A **classification badge** indicating stability — _Stable_, _Beta_ (yellow), _Prototype_ (red), or _Special_ (green)
+- The **node title** and **node pack** name
+- A brief **description**
+
+Click a node or press Enter to add it to the canvas. The node will be placed near the center of your current viewport, or at your cursor position if you opened the picker by dragging from a port.
+
+:::tip
+If you opened the picker by dragging from a port, the list is automatically filtered to show only nodes that have a compatible input or output for that connection. This is a fast way to discover which nodes work together.
+:::
+
+## Special Nodes
+
+In addition to invocation nodes (which perform image generation operations), the picker includes two special utility nodes:
+
+
+
+ A sticky-note text area for documenting your workflow. Useful for leaving yourself reminders or explaining sections of a complex graph to others.
+
+
+ Displays the current image being generated or the most recent output. Helpful for monitoring progress in long workflows.
+
+
+
+## Connecting Nodes
+
+Nodes have **input ports** on their left edge and **output ports** on their right edge. Ports are color-coded by data type so you can quickly identify compatible connections.
+
+
+1. **Drag from an output port** on one node toward the canvas.
+2. **Drop onto a compatible input port** on another node. Compatible ports will remain highlighted; incompatible ports will appear greyed out.
+3. A **bezier edge** is drawn between the two ports, representing the data flow.
+
+
+:::note
+You can also drop a connection onto a node without targeting a specific port. InvokeAI will automatically connect to the **first compatible port** it finds on that node.
+:::
+
+### Connection Rules
+
+- Connections must be between compatible data types (matching colors).
+- A node cannot connect to itself.
+- Each input port accepts only one connection (but an output can connect to many inputs).
+- Connections snap within a 30px radius of a port for easy targeting.
+
+### Reconnecting and Removing Edges
+
+- **Reconnect** an edge by dragging it from its current port to a new one.
+- **Remove** an edge by dragging it away from its port and releasing it over empty canvas.
+- **Delete** selected edges with Delete or Backspace .
+
+### Connectors
+
+Connectors are small editor-only nodes that exist purely to **reroute edges** for a cleaner-looking graph. They are saved with the workflow but are flattened out of the graph before execution, so the runtime never sees them — you cannot use them to add logic, only to tidy wiring.
+
+Ways to add a connector:
+
+- **Right-click empty canvas → Add Connector**, then drag connections to and from it.
+- **Double-click an existing edge** to insert a connector at that point, splicing it in.
+
+Other behaviors worth knowing:
+
+- **Target-first wiring works.** You can connect a connector's output to a downstream target field *before* hooking up its upstream source. The connector stays unresolved until a compatible source is connected; incompatible upstreams are rejected.
+- **Type compatibility is enforced** through the connector, exactly as for normal edges.
+- **Deleting a connector splices through** any edges that pass through it:
+ - `1 → 1`: the source is reconnected directly to the target.
+ - `1 → N`: the source is reconnected to every compatible downstream target.
+ - `1 → 0`: the connector is removed, no edges created.
+ - If a splice-through would produce an invalid graph, **Delete Connector** is disabled.
+- **Connectors persist** across workflow save / load.
+
+## Configuring Nodes
+
+Once a node is on the canvas, you can configure it by editing its input fields directly. Each node exposes a set of fields specific to its function — for example, a noise node has a **Seed** field, while a model loader has a **Model** selector.
+
+- **Inline editing** — Click on any input field to edit its value directly on the node.
+- **Renaming** — Right-click a node's title or any input label to rename it.
+- **Use Cache** — Toggle the caching option in the node footer to reuse previously computed values and speed up repeat runs.
+- **Collapse** — Click the collapse button on the node header to minimize it, keeping the canvas tidy.
+
+## Managing Nodes
+
+Use these shortcuts to work efficiently with nodes on the canvas:
+
+| Action | Shortcut |
+| :--- | :--- |
+| Add Node | Shift + A or Space |
+| Copy | Ctrl /Cmd + C |
+| Paste | Ctrl /Cmd + V |
+| Paste with Edges | Ctrl /Cmd + Shift + V |
+| Select All | Ctrl /Cmd + A |
+| Delete | Delete or Backspace |
+| Undo | Ctrl /Cmd + Z |
+| Redo | Ctrl /Cmd + Shift + Z |
+| Select Multiple | Shift + Click & Drag |
+
+:::tip
+All keyboard shortcuts are customizable. Open the Hotkeys modal with Shift + ? to view or change any binding.
+:::
+
+## Adding to Linear View
+
+Any input field on a node can be promoted to the **Linear View**, which provides a simplified UI for your workflow — perfect for sharing with others or for quick iteration.
+
+
+1. Right-click on an **input label** on any node.
+2. Select **"Add to Linear View"**.
+3. The input now appears in the Linear View panel, where you can adjust it without navigating the full graph.
+
+
+Custom names you set on input fields will carry over into the Linear View.
+
+## Installing Community Nodes
+
+InvokeAI's node system is extensible. Community-created nodes can add new capabilities to your workflows — from specialized image processing to LLM-powered prompt generation.
+
+The easiest way to install a community node pack is through the **[Custom Node Manager](/features/workflows/custom-node-manager/)**: paste a Git URL in the **Nodes** sidebar tab and the pack is cloned, loaded, and made available without a restart.
+
+If you prefer to install manually:
+
+
+1. Find a node pack from the [Community Nodes](/features/workflows/community-nodes/) list.
+2. Clone or download the node pack into the `nodes` folder inside your InvokeAI installation directory.
+3. In the Custom Node Manager, click **Reload** (or restart InvokeAI). The new nodes will appear in the node picker.
+
+
+:::note
+`git clone` is preferred over downloading a ZIP — it 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](/features/workflows/community-nodes/) page.
diff --git a/docs/assets/nodes/groupsallscale.png b/docs/src/content/docs/features/Workflows/assets/groupsallscale.png
similarity index 100%
rename from docs/assets/nodes/groupsallscale.png
rename to docs/src/content/docs/features/Workflows/assets/groupsallscale.png
diff --git a/docs/assets/nodes/groupsconditioning.png b/docs/src/content/docs/features/Workflows/assets/groupsconditioning.png
similarity index 100%
rename from docs/assets/nodes/groupsconditioning.png
rename to docs/src/content/docs/features/Workflows/assets/groupsconditioning.png
diff --git a/docs/assets/nodes/groupscontrol.png b/docs/src/content/docs/features/Workflows/assets/groupscontrol.png
similarity index 100%
rename from docs/assets/nodes/groupscontrol.png
rename to docs/src/content/docs/features/Workflows/assets/groupscontrol.png
diff --git a/docs/assets/nodes/groupsimgvae.png b/docs/src/content/docs/features/Workflows/assets/groupsimgvae.png
similarity index 100%
rename from docs/assets/nodes/groupsimgvae.png
rename to docs/src/content/docs/features/Workflows/assets/groupsimgvae.png
diff --git a/docs/assets/nodes/groupsiterate.png b/docs/src/content/docs/features/Workflows/assets/groupsiterate.png
similarity index 100%
rename from docs/assets/nodes/groupsiterate.png
rename to docs/src/content/docs/features/Workflows/assets/groupsiterate.png
diff --git a/docs/assets/nodes/groupslora.png b/docs/src/content/docs/features/Workflows/assets/groupslora.png
similarity index 100%
rename from docs/assets/nodes/groupslora.png
rename to docs/src/content/docs/features/Workflows/assets/groupslora.png
diff --git a/docs/assets/nodes/groupsmultigenseeding.png b/docs/src/content/docs/features/Workflows/assets/groupsmultigenseeding.png
similarity index 100%
rename from docs/assets/nodes/groupsmultigenseeding.png
rename to docs/src/content/docs/features/Workflows/assets/groupsmultigenseeding.png
diff --git a/docs/assets/nodes/groupsnoise.png b/docs/src/content/docs/features/Workflows/assets/groupsnoise.png
similarity index 100%
rename from docs/assets/nodes/groupsnoise.png
rename to docs/src/content/docs/features/Workflows/assets/groupsnoise.png
diff --git a/docs/assets/nodes/linearview.png b/docs/src/content/docs/features/Workflows/assets/linearview.png
similarity index 100%
rename from docs/assets/nodes/linearview.png
rename to docs/src/content/docs/features/Workflows/assets/linearview.png
diff --git a/docs/assets/nodes/nodescontrol.png b/docs/src/content/docs/features/Workflows/assets/nodescontrol.png
similarity index 100%
rename from docs/assets/nodes/nodescontrol.png
rename to docs/src/content/docs/features/Workflows/assets/nodescontrol.png
diff --git a/docs/assets/nodes/nodesi2i.png b/docs/src/content/docs/features/Workflows/assets/nodesi2i.png
similarity index 100%
rename from docs/assets/nodes/nodesi2i.png
rename to docs/src/content/docs/features/Workflows/assets/nodesi2i.png
diff --git a/docs/assets/nodes/nodest2i.png b/docs/src/content/docs/features/Workflows/assets/nodest2i.png
similarity index 100%
rename from docs/assets/nodes/nodest2i.png
rename to docs/src/content/docs/features/Workflows/assets/nodest2i.png
diff --git a/docs/assets/nodes/workflow_library.png b/docs/src/content/docs/features/Workflows/assets/workflow_library.png
similarity index 100%
rename from docs/assets/nodes/workflow_library.png
rename to docs/src/content/docs/features/Workflows/assets/workflow_library.png
diff --git a/docs/src/content/docs/features/Workflows/comfyui-migration.mdx b/docs/src/content/docs/features/Workflows/comfyui-migration.mdx
new file mode 100644
index 00000000000..164a778925b
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/comfyui-migration.mdx
@@ -0,0 +1,119 @@
+---
+title: ComfyUI Migration
+lastUpdated: 2026-05-23
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+If you're coming to InvokeAI from ComfyUI, welcome! You'll find things are similar but different - the good news is that you already know how things should work, and it's just a matter of wiring them up!
+
+
+ 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.
+
+
+ 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!
+
+
+## Node Equivalents
+
+Finding the right node is the hardest part of switching. Use the categories below to find the InvokeAI equivalents for the ComfyUI nodes you are used to.
+
+### Sampling
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| KSampler | Denoise Latents |
+| Ksampler Advanced | Denoise Latents |
+
+### Loaders
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| Load Checkpoint | Main Model Loader _or_ SDXL Main Model Loader |
+| Load VAE | VAE Loader |
+| Load Lora | LoRA Loader _or_ SDXL Lora Loader |
+| Load ControlNet Model | ControlNet |
+| Load ControlNet Model (diff) | ControlNet |
+| Load Style Model | Reference Only ControlNet will be coming in a future version of InvokeAI |
+| unCLIPCheckpointLoader | N/A |
+| GLIGENLoader | N/A |
+| Hypernetwork Loader | N/A |
+| Load Upscale Model | Occurs within "Upscale (RealESRGAN)" |
+
+### Conditioning
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| CLIP Text Encode (Prompt) | Compel (Prompt) or SDXL Compel (Prompt) |
+| CLIP Set Last Layer | CLIP Skip |
+| Conditioning (Average) | Use the .blend() feature of prompts |
+| Conditioning (Combine) | N/A |
+| Conditioning (Concat) | See the Prompt Tools Community Node |
+| Conditioning (Set Area) | N/A |
+| Conditioning (Set Mask) | Mask Edge |
+| CLIP Vision Encode | N/A |
+| unCLIPConditioning | N/A |
+| Apply ControlNet | ControlNet |
+| Apply ControlNet (Advanced) | ControlNet |
+
+### Latent
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| VAE Decode | Latents to Image |
+| VAE Encode | Image to Latents |
+| Empty Latent Image | Noise |
+| Upscale Latent | Resize Latents |
+| Upscale Latent By | Scale Latents |
+| Latent Composite | Blend Latents |
+| LatentCompositeMasked | N/A |
+
+### Image
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| Save Image | Image |
+| Preview Image | Current |
+| Load Image | Image |
+| Empty Image | Blank Image |
+| Invert Image | Invert Lerp Image |
+| Batch Images | Link "Image" nodes into an "Image Collection" node |
+| Pad Image for Outpainting | Outpainting is easily accomplished in the Unified Canvas |
+| ImageCompositeMasked | Paste Image |
+| Upscale Image | Resize Image |
+| Upscale Image By | Upscale Image |
+| Upscale Image (using Model) | Upscale Image |
+| ImageBlur | Blur Image |
+| ImageQuantize | N/A |
+| ImageSharpen | N/A |
+| Canny | Canny Processor |
+
+### Mask
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| Load Image (as Mask) | Image |
+| Convert Mask to Image | Image |
+| Convert Image to Mask | Image |
+| SolidMask | N/A |
+| InvertMask | Invert Lerp Image |
+| CropMask | Crop Image |
+| MaskComposite | Combine Mask |
+| FeatherMask | Blur Image |
+
+### Advanced
+
+| ComfyUI Node | Invoke Equivalent |
+| :--- | :--- |
+| Load CLIP | Main Model Loader _or_ SDXL Main Model Loader |
+| UNETLoader | Main Model Loader _or_ SDXL Main Model Loader |
+| DualCLIPLoader | Main Model Loader _or_ SDXL Main Model Loader |
+| Load Checkpoint | Main Model Loader _or_ SDXL Main Model Loader |
+| ConditioningZeroOut | N/A |
+| ConditioningSetTimestepRange | N/A |
+| CLIPTextEncodeSDXLRefiner | Compel (Prompt) or SDXL Compel (Prompt) |
+| CLIPTextEncodeSDXL | Compel (Prompt) or SDXL Compel (Prompt) |
+| ModelMergeSimple | Model Merging is available in the Model Manager |
+| ModelMergeBlocks | Model Merging is available in the Model Manager |
+| CheckpointSave | Model saving is available in the Model Manager |
+| CLIPMergeSimple | N/A |
diff --git a/docs/src/content/docs/features/Workflows/community-nodes.mdx b/docs/src/content/docs/features/Workflows/community-nodes.mdx
new file mode 100644
index 00000000000..66ca6bbdf64
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/community-nodes.mdx
@@ -0,0 +1,731 @@
+---
+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](/concepts/nodes-workflows/).
+
+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.
+
+The suggested method is to use `git clone` to clone the repository the node is found in. This allows for easy updates of the node in the future.
+
+If you'd prefer, you can also just download the whole node folder from the linked repository and add it to the `nodes` folder.
+
+To use a community workflow, download the `.json` node graph file and load it into Invoke AI via the **Load Workflow** button in the Workflow Editor.
+
+---
+
+### Anamorphic Tools
+
+**Description:** A set of nodes to perform anamorphic modifications to images, like lens blur, streaks, spherical distortion, and vignetting.
+
+**Node Link:** https://github.com/JPPhoto/anamorphic-tools
+
+---
+
+### Adapters Linked Nodes
+
+**Description:** A set of nodes for linked adapters (ControlNet, IP-Adaptor & T2I-Adapter). This allows multiple adapters to be chained together without using a `collect` node which means it can be used inside an `iterate` node without any collecting on every iteration issues.
+
+- `ControlNet-Linked` - Collects ControlNet info to pass to other nodes.
+- `IP-Adapter-Linked` - Collects IP-Adapter info to pass to other nodes.
+- `T2I-Adapter-Linked` - Collects T2I-Adapter info to pass to other nodes.
+
+Note: These are inherited from the core nodes so any update to the core nodes should be reflected in these.
+
+**Node Link:** https://github.com/skunkworxdark/adapters-linked-nodes
+
+---
+
+### Autostereogram Nodes
+
+**Description:** Generate autostereogram images from a depth map. This is not a very practically useful node but more a 90s nostalgic indulgence as I used to love these images as a kid.
+
+**Node Link:** https://github.com/skunkworxdark/autostereogram_nodes
+
+**Example Usage:**
+
+ -> ->
+
+---
+
+### Average Images
+
+**Description:** This node takes in a collection of images of the same size and averages them as output. It converts everything to RGB mode first.
+
+**Node Link:** https://github.com/JPPhoto/average-images-node
+
+---
+
+### BiRefNet Background Removal
+
+**Description:** Remove image backgrounds using BiRefNet (Bilateral Reference Network), a high-quality segmentation model. Supports multiple model variants including standard, high-resolution, matting, portrait, and specialized models for different use cases.
+
+**Node Link:** https://github.com/veeliks/invoke_birefnet
+
+**Output Examples**
+
+
+
+
+
+
+---
+
+### Clean Image Artifacts After Cut
+
+Description: Removes residual artifacts after an image is separated from its background.
+
+Node Link: https://github.com/VeyDlin/clean-artifact-after-cut-node
+
+View:
+
+
+
+---
+
+### Close Color Mask
+
+Description: Generates a mask for images based on a closely matching color, useful for color-based selections.
+
+Node Link: https://github.com/VeyDlin/close-color-mask-node
+
+View:
+
+
+
+---
+
+### Clothing Mask
+
+Description: Employs a U2NET neural network trained for the segmentation of clothing items in images.
+
+Node Link: https://github.com/VeyDlin/clothing-mask-node
+
+View:
+
+
+
+---
+
+### Contrast Limited Adaptive Histogram Equalization
+
+Description: Enhances local image contrast using adaptive histogram equalization with contrast limiting.
+
+Node Link: https://github.com/VeyDlin/clahe-node
+
+View:
+
+
+
+---
+
+### Curves
+
+**Description:** Adjust an image's curve based on a user-defined string.
+
+**Node Link:** https://github.com/JPPhoto/curves-node
+
+---
+
+### Depth Map from Wavefront OBJ
+
+**Description:** Render depth maps from Wavefront .obj files (triangulated) using this simple 3D renderer utilizing numpy and matplotlib to compute and color the scene. There are simple parameters to change the FOV, camera position, and model orientation.
+
+To be imported, an .obj must use triangulated meshes, so make sure to enable that option if exporting from a 3D modeling program. This renderer makes each triangle a solid color based on its average depth, so it will cause anomalies if your .obj has large triangles. In Blender, the Remesh modifier can be helpful to subdivide a mesh into small pieces that work well given these limitations.
+
+**Node Link:** https://github.com/dwringer/depth-from-obj-node
+
+**Example Usage:**
+
+
+
+---
+
+### Enhance Detail
+
+**Description:** A single node that can enhance the detail in an image. Increase or decrease details in an image using a guided filter (as opposed to the typical Gaussian blur used by most sharpening filters.) Based on the `Enhance Detail` ComfyUI node from https://github.com/spacepxl/ComfyUI-Image-Filters
+
+**Node Link:** https://github.com/skunkworxdark/enhance-detail-node
+
+**Example Usage:**
+
+
+
+---
+
+### Film Grain
+
+**Description:** This node adds a film grain effect to the input image based on the weights, seeds, and blur radii parameters. It works with RGB input images only.
+
+**Node Link:** https://github.com/JPPhoto/film-grain-node
+
+---
+
+### Flip Pose
+
+**Description:** This node will flip an openpose image horizontally, recoloring it to make sure that it isn't facing the wrong direction. Note that it does not work with openpose hands.
+
+**Node Link:** https://github.com/JPPhoto/flip-pose-node
+
+---
+
+### Flux Ideal Size
+
+**Description:** This node returns an ideal size to use for the first stage of a Flux image generation pipeline. Generating at the right size helps limit duplication and odd subject placement.
+
+**Node Link:** https://github.com/JPPhoto/flux-ideal-size
+
+---
+
+### Generative Grammar-Based Prompt Nodes
+
+**Description:** This set of 3 nodes generates prompts from simple user-defined grammar rules (loaded from custom files - examples provided below). The prompts are made by recursively expanding a special template string, replacing nonterminal "parts-of-speech" until no nonterminal terms remain in the string.
+
+This includes 3 Nodes:
+- *Lookup Table from File* - loads a YAML file "prompt" section (or of a whole folder of YAML's) into a JSON-ified dictionary (Lookups output)
+- *Lookups Entry from Prompt* - places a single entry in a new Lookups output under the specified heading
+- *Prompt from Lookup Table* - uses a Collection of Lookups as grammar rules from which to randomly generate prompts.
+
+**Node Link:** https://github.com/dwringer/generative-grammar-prompt-nodes
+
+**Example Usage:**
+
+
+
+---
+
+### GPT2RandomPromptMaker
+
+**Description:** A node for InvokeAI utilizes the GPT-2 language model to generate random prompts based on a provided seed and context.
+
+**Node Link:** https://github.com/mickr777/GPT2RandomPromptMaker
+
+**Output Examples**
+
+Generated Prompt: An enchanted weapon will be usable by any character regardless of their alignment.
+
+
+
+---
+
+### Grid to Gif
+
+**Description:** One node that turns a grid image into an image collection, one node that turns an image collection into a gif.
+
+**Node Link:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/GridToGif.py
+
+**Example Node Graph:** https://github.com/mildmisery/invokeai-GridToGifNode/blob/main/Grid%20to%20Gif%20Example%20Workflow.json
+
+**Output Examples**
+
+
+
+
+---
+
+### Halftone
+
+**Description**: Halftone converts the source image to grayscale and then performs halftoning. CMYK Halftone converts the image to CMYK and applies a per-channel halftoning to make the source image look like a magazine or newspaper. For both nodes, you can specify angles and halftone dot spacing.
+
+**Node Link:** https://github.com/JPPhoto/halftone-node
+
+**Example**
+
+Input:
+
+
+
+Halftone Output:
+
+
+
+CMYK Halftone Output:
+
+
+
+---
+
+### Hand Refiner with MeshGraphormer
+
+**Description**: Hand Refiner takes in your image and automatically generates a fixed depth map for the hands along with a mask of the hands region that will conveniently allow you to use them along with ControlNet to fix the wonky hands generated by Stable Diffusion
+
+**Node Link:** https://github.com/blessedcoolant/invoke_meshgraphormer
+
+**View**
+
+
+---
+
+### Image and Mask Composition Pack
+
+**Description:** This is a pack of nodes for composing masks and images, including a simple text mask creator and both image and latent offset nodes. The offsets wrap around, so these can be used in conjunction with the Seamless node to progressively generate centered on different parts of the seamless tiling.
+
+This includes 15 Nodes:
+
+- *Adjust Image Hue Plus* - Rotate the hue of an image in one of several different color spaces.
+- *Blend Latents/Noise (Masked)* - Use a mask to blend part of one latents tensor [including Noise outputs] into another. Can be used to "renoise" sections during a multi-stage [masked] denoising process.
+- *Enhance Image* - Boost or reduce color saturation, contrast, brightness, sharpness, or invert colors of any image at any stage with this simple wrapper for pillow [PIL]'s ImageEnhance module.
+- *Equivalent Achromatic Lightness* - Calculates image lightness accounting for Helmholtz-Kohlrausch effect based on a method described by High, Green, and Nussbaum (2023).
+- *Text to Mask (Clipseg)* - Input a prompt and an image to generate a mask representing areas of the image matched by the prompt.
+- *Text to Mask Advanced (Clipseg)* - Output up to four prompt masks combined with logical "and", logical "or", or as separate channels of an RGBA image.
+- *Image Layer Blend* - Perform a layered blend of two images using alpha compositing. Opacity of top layer is selectable, with optional mask and several different blend modes/color spaces.
+- *Image Compositor* - Take a subject from an image with a flat backdrop and layer it on another image using a chroma key or flood select background removal.
+- *Image Dilate or Erode* - Dilate or expand a mask (or any image!). This is equivalent to an expand/contract operation.
+- *Image Value Thresholds* - Clip an image to pure black/white beyond specified thresholds.
+- *Offset Latents* - Offset a latents tensor in the vertical and/or horizontal dimensions, wrapping it around.
+- *Offset Image* - Offset an image in the vertical and/or horizontal dimensions, wrapping it around.
+- *Rotate/Flip Image* - Rotate an image in degrees clockwise/counterclockwise about its center, optionally resizing the image boundaries to fit, or flipping it about the vertical and/or horizontal axes.
+- *Shadows/Highlights/Midtones* - Extract three masks (with adjustable hard or soft thresholds) representing shadows, midtones, and highlights regions of an image.
+- *Text Mask (simple 2D)* - create and position a white on black (or black on white) line of text using any font locally available to Invoke.
+
+**Node Link:** https://github.com/dwringer/composition-nodes
+
+
+
+---
+
+### Image Dominant Color
+
+Description: Identifies and extracts the dominant color from an image using k-means clustering.
+
+Node Link: https://github.com/VeyDlin/image-dominant-color-node
+
+View:
+
+
+
+---
+
+### Image Export
+
+**Description:** Export images in multiple formats (AVIF, JPEG, PNG, TIFF, WebP) with format-specific compression and quality options.
+
+**Node Link:** https://github.com/veeliks/invoke_image_export
+
+**Nodes:**
+
+
+
+---
+
+### Image to Character Art Image Nodes
+
+**Description:** Group of nodes to convert an input image into ascii/unicode art Image
+
+**Node Link:** https://github.com/mickr777/imagetoasciiimage
+
+**Output Examples**
+
+
+
+
+
+
+
+
+
+---
+
+### Image Picker
+
+**Description:** This InvokeAI node takes in a collection of images and randomly chooses one. This can be useful when you have a number of poses to choose from for a ControlNet node, or a number of input images for another purpose.
+
+**Node Link:** https://github.com/JPPhoto/image-picker-node
+
+---
+
+### Image Resize Plus
+
+Description: Provides various image resizing options such as fill, stretch, fit, center, and crop.
+
+Node Link: https://github.com/VeyDlin/image-resize-plus-node
+
+View:
+
+
+
+---
+
+### Latent Upscale
+
+**Description:** This node uses a small (~2.4mb) model to upscale the latents used in a Stable Diffusion 1.5 or Stable Diffusion XL image generation, rather than the typical interpolation method, avoiding the traditional downsides of the latent upscale technique.
+
+**Node Link:** [https://github.com/gogurtenjoyer/latent-upscale](https://github.com/gogurtenjoyer/latent-upscale)
+
+---
+
+### Load Video Frame
+
+**Description:** This is a video frame image provider + indexer/video creation nodes for hooking up to iterators and ranges and ControlNets and such for invokeAI node experimentation. Think animation + ControlNet outputs.
+
+**Node Link:** https://github.com/helix4u/load_video_frame
+
+**Output Example:**
+
+
+
+---
+### Make 3D
+
+**Description:** Create compelling 3D stereo images from 2D originals.
+
+**Node Link:** [https://gitlab.com/srcrr/shift3d/-/raw/main/make3d.py](https://gitlab.com/srcrr/shift3d)
+
+**Example Node Graph:** https://gitlab.com/srcrr/shift3d/-/raw/main/example-workflow.json?ref_type=heads&inline=false
+
+**Output Examples**
+
+
+
+
+---
+
+### Mask Operations
+
+Description: Offers logical operations (OR, SUB, AND) for combining and manipulating image masks.
+
+Node Link: https://github.com/VeyDlin/mask-operations-node
+
+View:
+
+
+
+---
+
+### Match Histogram
+
+**Description:** An InvokeAI node to match a histogram from one image to another. This is a bit like the `color correct` node in the main InvokeAI but this works in the YCbCr colourspace and can handle images of different sizes. Also does not require a mask input.
+- Option to only transfer luminance channel.
+- Option to save output as grayscale
+
+A good use case for this node is to normalize the colors of an image that has been through the tiled scaling workflow of my XYGrid Nodes.
+
+See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md
+
+**Node Link:** https://github.com/skunkworxdark/match_histogram
+
+**Output Examples**
+
+
+
+---
+
+### Metadata Linked Nodes
+
+**Description:** A set of nodes for Metadata. Collect Metadata from within an `iterate` node & extract metadata from an image.
+
+- `Metadata Item Linked` - Allows collecting of metadata while within an iterate node with no need for a collect node or conversion to metadata node
+- `Metadata From Image` - Provides Metadata from an image
+- `Metadata To String` - Extracts a String value of a label from metadata
+- `Metadata To Integer` - Extracts an Integer value of a label from metadata
+- `Metadata To Float` - Extracts a Float value of a label from metadata
+- `Metadata To Scheduler` - Extracts a Scheduler value of a label from metadata
+- `Metadata To Bool` - Extracts Bool types from metadata
+- `Metadata To Model` - Extracts model types from metadata
+- `Metadata To SDXL Model` - Extracts SDXL model types from metadata
+- `Metadata To LoRAs` - Extracts Loras from metadata.
+- `Metadata To SDXL LoRAs` - Extracts SDXL Loras from metadata
+- `Metadata To ControlNets` - Extracts ControNets from metadata
+- `Metadata To IP-Adapters` - Extracts IP-Adapters from metadata
+- `Metadata To T2I-Adapters` - Extracts T2I-Adapters from metadata
+- `Denoise Latents + Metadata` - This is an inherited version of the existing `Denoise Latents` node but with a metadata input and output.
+
+**Node Link:** https://github.com/skunkworxdark/metadata-linked-nodes
+
+---
+
+### Negative Image
+
+Description: Creates a negative version of an image, effective for visual effects and mask inversion.
+
+Node Link: https://github.com/VeyDlin/negative-image-node
+
+View:
+
+
+
+---
+
+### Nightmare Promptgen
+
+**Description:** Nightmare Prompt Generator - Uses a local text generation model to create unique imaginative (but usually nightmarish) prompts for InvokeAI. By default, it allows you to choose from some gpt-neo models I finetuned on over 2500 of my own InvokeAI prompts in Compel format, but you're able to add your own, as well. Offers support for replacing any troublesome words with a random choice from list you can also define.
+
+**Node Link:** [https://github.com/gogurtenjoyer/nightmare-promptgen](https://github.com/gogurtenjoyer/nightmare-promptgen)
+
+---
+
+### Ollama Node
+
+**Description:** Uses Ollama API to expand text prompts for text-to-image generation using local LLMs. Works great for expanding basic prompts into detailed natural language prompts for Flux. Also provides a toggle to unload the LLM model immediately after expanding, to free up VRAM for Invoke to continue the image generation workflow.
+
+**Node Link:** https://github.com/Jonseed/Ollama-Node
+
+**Example Node Graph:** https://github.com/Jonseed/Ollama-Node/blob/main/Ollama-Node-Flux-example.json
+
+**View:**
+
+
+
+---
+
+### One Button Prompt
+
+
+
+**Description:** an extensive suite of auto prompt generation and prompt helper nodes based on extensive logic. Get creative with the best prompt generator in the world.
+
+The main node generates interesting prompts based on a set of parameters. There are also some additional nodes such as Auto Negative Prompt, One Button Artify, Create Prompt Variant and other cool prompt toys to play around with.
+
+**Node Link:** [https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI](https://github.com/AIrjen/OneButtonPrompt_X_InvokeAI)
+
+**Nodes:**
+
+
+
+---
+
+### Oobabooga
+
+**Description:** asks a local LLM running in Oobabooga's Text-Generation-Webui to write a prompt based on the user input.
+
+**Link:** https://github.com/sammyf/oobabooga-node
+
+**Example:**
+
+"describe a new mystical creature in its natural environment"
+
+*can return*
+
+"The mystical creature I am describing to you is called the "Glimmerwing". It is a majestic, iridescent being that inhabits the depths of the most enchanted forests and glimmering lakes. Its body is covered in shimmering scales that reflect every color of the rainbow, and it has delicate, translucent wings that sparkle like diamonds in the sunlight. The Glimmerwing's home is a crystal-clear lake, surrounded by towering trees with leaves that shimmer like jewels. In this serene environment, the Glimmerwing spends its days swimming gracefully through the water, chasing schools of glittering fish and playing with the gentle ripples of the lake's surface.
+As the sun sets, the Glimmerwing perches on a branch of one of the trees, spreading its wings to catch the last rays of light. The creature's scales glow softly, casting a rainbow of colors across the forest floor. The Glimmerwing sings a haunting melody, its voice echoing through the stillness of the night air. Its song is said to have the power to heal the sick and bring peace to troubled souls. Those who are lucky enough to hear the Glimmerwing's song are forever changed by its beauty and grace."
+
+
+
+**Requirement**
+
+a Text-Generation-Webui instance (might work remotely too, but I never tried it) and obviously InvokeAI 3.x
+
+**Note**
+
+This node works best with SDXL models, especially as the style can be described independently of the LLM's output.
+
+---
+
+### Prompt Tools
+
+**Description:** A set of InvokeAI nodes that add general prompt (string) manipulation tools. Designed to accompany the `Prompts From File` node and other prompt generation nodes.
+
+1. `Prompt To File` - saves a prompt or collection of prompts to a file. one per line. There is an append/overwrite option.
+2. `PTFields Collect` - Converts image generation fields into a Json format string that can be passed to Prompt to file.
+3. `PTFields Expand` - Takes Json string and converts it to individual generation parameters. This can be fed from the Prompts to file node.
+4. `Prompt Strength` - Formats prompt with strength like the weighted format of compel
+5. `Prompt Strength Combine` - Combines weighted prompts for .and()/.blend()
+6. `CSV To Index String` - Gets a string from a CSV by index. Includes a Random index option
+
+The following Nodes are now included in v3.2 of Invoke and are no longer in this set of tools.
+
+- `Prompt Join` -> `String Join`
+- `Prompt Join Three` -> `String Join Three`
+- `Prompt Replace` -> `String Replace`
+- `Prompt Split Neg` -> `String Split Neg`
+
+
+See full docs here: https://github.com/skunkworxdark/Prompt-tools-nodes/edit/main/README.md
+
+**Node Link:** https://github.com/skunkworxdark/Prompt-tools-nodes
+
+**Workflow Examples**
+
+
+
+---
+
+### Remote Image
+
+**Description:** This is a pack of nodes to interoperate with other services, be they public websites or bespoke local servers. The pack consists of these nodes:
+
+- *Load Remote Image* - Lets you load remote images such as a realtime webcam image, an image of the day, or dynamically created images.
+- *Post Image to Remote Server* - Lets you upload an image to a remote server using an HTTP POST request, eg for storage, display or further processing.
+
+**Node Link:** https://github.com/fieldOfView/InvokeAI-remote_image
+
+---
+
+### BriaAI Remove Background
+
+**Description**: Implements one click background removal with BriaAI's new version 1.4 model which seems to be producing better results than any other previous background removal tool.
+
+**Node Link:** https://github.com/blessedcoolant/invoke_bria_rmbg
+
+**View**
+
+
+---
+
+### Remove Background
+
+Description: An integration of the rembg package to remove backgrounds from images using multiple U2NET models.
+
+Node Link: https://github.com/VeyDlin/remove-background-node
+
+View:
+
+
+
+---
+
+### Retroize
+
+**Description:** Retroize is a collection of nodes for InvokeAI to "Retroize" images. Any image can be given a fresh coat of retro paint with these nodes, either from your gallery or from within the graph itself. It includes nodes to pixelize, quantize, palettize, and ditherize images; as well as to retrieve palettes from existing images.
+
+**Node Link:** https://github.com/Ar7ific1al/invokeai-retroizeinode/
+
+**Retroize Output Examples**
+
+
+
+---
+
+### Stereogram Nodes
+
+**Description:** A set of custom nodes for InvokeAI to create cross-view or parallel-view stereograms. Stereograms are 2D images that, when viewed properly, reveal a 3D scene. Check out [r/crossview](https://www.reddit.com/r/CrossView/) for tutorials.
+
+**Node Link:** https://github.com/simonfuhrmann/invokeai-stereo
+
+**Example Workflow and Output**
+
+
+
+---
+
+### Simple Skin Detection
+
+Description: Detects skin in images based on predefined color thresholds.
+
+Node Link: https://github.com/VeyDlin/simple-skin-detection-node
+
+View:
+
+
+
+---
+
+### Size Stepper Nodes
+
+**Description:** This is a set of nodes for calculating the necessary size increments for doing upscaling workflows. Use the *Final Size & Orientation* node to enter your full size dimensions and orientation (portrait/landscape/random), then plug that and your initial generation dimensions into the *Ideal Size Stepper* and get 1, 2, or 3 intermediate pairs of dimensions for upscaling. Note this does not output the initial size or full size dimensions: the 1, 2, or 3 outputs of this node are only the intermediate sizes.
+
+A third node is included, *Random Switch (Integers)*, which is just a generic version of Final Size with no orientation selection.
+
+**Node Link:** https://github.com/dwringer/size-stepper-nodes
+
+**Example Usage:**
+
+
+
+---
+
+### Text font to Image
+
+**Description:** text font to text image node for InvokeAI, download a font to use (or if in font cache uses it from there), the text is always resized to the image size, but can control that with padding, optional 2nd line
+
+**Node Link:** https://github.com/mickr777/textfontimage
+
+**Output Examples**
+
+
+
+Results after using the depth controlnet
+
+
+
+
+
+---
+
+### Thresholding
+
+**Description:** This node generates masks for highlights, midtones, and shadows given an input image. You can optionally specify a blur for the lookup table used in making those masks from the source image.
+
+**Node Link:** https://github.com/JPPhoto/thresholding-node
+
+**Examples**
+
+Input:
+
+
+
+Highlights/Midtones/Shadows:
+
+
+
+
+
+Highlights/Midtones/Shadows (with LUT blur enabled):
+
+
+
+
+
+---
+
+### Unsharp Mask
+
+**Description:** Applies an unsharp mask filter to an image, preserving its alpha channel in the process.
+
+**Node Link:** https://github.com/JPPhoto/unsharp-mask-node
+
+---
+
+### XY Image to Grid and Images to Grids nodes
+
+**Description:** These nodes add the following to InvokeAI:
+- Generate grids of images from multiple input images
+- Create XY grid images with labels from parameters
+- Split images into overlapping tiles for processing (for super-resolution workflows)
+- Recombine image tiles into a single output image blending the seams
+
+The nodes include:
+1. `Images To Grids` - Combine multiple images into a grid of images
+2. `XYImage To Grid` - Take X & Y params and creates a labeled image grid.
+3. `XYImage Tiles` - Super-resolution (embiggen) style tiled resizing
+4. `Image Tot XYImages` - Takes an image and cuts it up into a number of columns and rows.
+5. Multiple supporting nodes - Helper nodes for data wrangling and building `XYImage` collections
+
+See full docs here: https://github.com/skunkworxdark/XYGrid_nodes/edit/main/README.md
+
+**Node Link:** https://github.com/skunkworxdark/XYGrid_nodes
+
+**Output Examples**
+
+
+
+---
+
+### Example Node Template
+
+**Description:** This node allows you to do super cool things with InvokeAI.
+
+**Node Link:** https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/app/invocations/prompt.py
+
+**Example Workflow:** https://github.com/invoke-ai/InvokeAI/blob/docs/main/docs/workflows/Prompt_from_File.json
+
+**Output Examples**
+
+
+
+
+## Disclaimer
+
+The nodes linked have been developed and contributed by members of the Invoke AI community. While we strive to ensure the quality and safety of these contributions, we do not guarantee the reliability or security of the nodes. If you have issues or concerns with any of the nodes below, please raise it on GitHub or in the Discord.
+
+
+## Help
+If you run into any issues with a node, please post in the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy).
diff --git a/docs/src/content/docs/features/Workflows/custom-node-manager.mdx b/docs/src/content/docs/features/Workflows/custom-node-manager.mdx
new file mode 100644
index 00000000000..ab42b73e5a6
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/custom-node-manager.mdx
@@ -0,0 +1,76 @@
+---
+title: Custom Node Manager
+sidebar:
+ order: 5
+---
+
+import { Steps } from '@astrojs/starlight/components';
+
+The Custom Node Manager installs, updates, and removes community node packs directly from the InvokeAI UI — no manual file copying, no restart required.
+
+## Opening the Custom Node Manager
+
+Click the **Nodes** tab (circuit icon) in the left sidebar, between **Models** and **Queue**.
+
+The page is split into two panels:
+
+- **Left:** the list of installed node packs, with each pack's node count, type badges, and on-disk path.
+- **Right:** the install UI, with tabs for **Git Repository URL** and **Scan Folder**, plus an install log at the bottom.
+
+## Installing a node pack
+
+
+1. On the right panel, choose the **Git Repository URL** tab.
+2. Paste the Git URL of the pack, e.g. `https://github.com/user/my-node-pack.git`.
+3. Click **Install**.
+
+
+What happens during install:
+
+- The repo is cloned into your `nodes` directory.
+- The nodes are loaded into the running InvokeAI process immediately — **no restart needed**.
+- Any workflow `.json` files found in the repo are imported into your workflow library and tagged with `node-pack:` so you can filter for them.
+- The install log at the bottom of the panel shows the result for each step.
+
+:::caution[Security]
+Custom nodes execute arbitrary Python on your machine. **Only install node packs from authors you trust.** A malicious pack could harm your system or exfiltrate data.
+:::
+
+### Python dependencies
+
+The Custom Node Manager **does not** automatically run `pip install` for a pack's `requirements.txt` or `pyproject.toml`. Auto-installing into the running InvokeAI environment risks pulling in incompatible package versions and breaking the application.
+
+If a pack ships extra dependencies, you'll see a warning toast after installation. Install them yourself — typically `pip install -r requirements.txt` from inside an activated InvokeAI environment, but check the pack's README first. After installing, click **Reload** so the new dependencies take effect.
+
+## Managing installed packs
+
+Each entry in the left panel has actions for managing the pack:
+
+- **Reload** — re-scans the `nodes` directory. Use this after manually adding a pack via `git clone`, or after installing extra Python dependencies.
+- **Uninstall** — removes the pack from disk, unregisters its nodes from the running process, and removes any workflows that were imported from the pack. No restart needed.
+
+## Scan Folder tab
+
+The **Scan Folder** tab shows the path of your `nodes` directory. Anything placed there manually (for example, by `git clone`-ing a pack directly) is detected automatically at startup. Use **Reload** to pick up packs added at runtime.
+
+## Troubleshooting
+
+### Install fails
+
+- Confirm the Git URL is correct and reachable.
+- The repo must contain an `__init__.py` at its root.
+- Read the install log — it surfaces the underlying error.
+
+### Nodes don't appear after install
+
+- Click **Reload**.
+- Check that the pack's `__init__.py` imports the node classes.
+- Check the server console for import errors.
+
+### Workflows show errors after uninstalling
+
+User-created workflows that reference nodes from an uninstalled pack will show errors for the missing node types. Either reinstall the pack or remove the affected nodes from the workflow.
+
+## Authoring a node pack
+
+If you want to publish your own pack so it can be installed by URL, see the [Creating a Node Pack](/development/guides/creating-nodes/) developer guide for the required repository layout, `__init__.py` requirements, and conventions for shipping workflows alongside your nodes.
diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx
new file mode 100644
index 00000000000..bce33485ad6
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/editor-interface.mdx
@@ -0,0 +1,141 @@
+---
+title: Editor Interface
+description: Learn how to use the Workflow Editor in InvokeAI.
+sidebar:
+ order: 2
+lastUpdated: 2026-02-20
+---
+
+import { Card, CardGrid, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+
+The workflow editor is a blank canvas allowing for the use of individual functions and image transformations to control the image generation workflow. Nodes take in inputs on the left side of the node, and return an output on the right side of the node.
+
+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.
+:::
+
+## Features
+
+
+ Save workflows to the Invoke database, allowing you to easily create, modify, and share workflows as needed. A curated set of default workflows is provided to help explain important node usage.
+
+ 
+
+ The library has two views:
+
+ - **Browse Workflows** lists curated default workflows, filterable by a fixed set of category tags.
+ - **Your Workflows** lists workflows you have saved. The tag filter here is **dynamic** — it shows every unique tag found across your own workflows, with a count per tag.
+
+ Add comma-separated tags to a workflow (e.g. `portrait, SDXL, upscaling`) when saving it. The tags appear at the bottom of each workflow tile in the library and become selectable filters in the sidebar. Click one or more tags to narrow the list; click **Your Workflows** to clear the filter and show everything again. Tag counts update automatically when you create, edit, or delete a workflow.
+
+
+ Create a custom UI for your workflow, making it easier to iterate on your generations. The Linear UI View is saved alongside the workflow, allowing you to share workflows and enable others to use them.
+
+
+ 1. Right-click on any **input label** on a node.
+ 2. Select **"Add to Linear View"**.
+ 3. The input will now appear in your Linear View panel!
+
+
+ 
+
+
+ Any node or input field can be renamed in the workflow editor. If the input field you have renamed has been added to the Linear View, the changed name will be reflected in both places.
+
+
+ Nodes have a **"Use Cache"** option in their footer. This allows for performance improvements by reusing previously cached values during workflow processing.
+
+
+### Managing Nodes
+
+Use these quick keyboard shortcuts to navigate and manage your workflow efficiently:
+
+
+
+ Ctrl + C (or Cmd + C )
+
+
+ Ctrl + V (or Cmd + V )
+
+
+ Shift + Click & Drag
+
+
+ Backspace / Delete
+
+
+
+## Important Nodes & Concepts
+
+There are several node grouping concepts that can be examined with a narrow focus. These (and other) groupings can be pieced together to make up functional graph setups, and are important to understanding how groups of nodes work together as part of a whole.
+
+:::note
+The screenshots below aren't examples of complete functioning node graphs, but rather snippets demonstrating specific concepts.
+:::
+
+
+
+ ### Create Latent Noise
+ An initial noise tensor is necessary for the latent diffusion process. As a result, the Denoising node requires a noise node input.
+
+ The standard **Create Latent Noise** node now includes a **Noise Type** selector for architecture-specific latent
+ shapes. Leave it at **SD** for classic 4-channel Stable Diffusion workflows, or switch it to the architecture that
+ matches the downstream denoiser when working with models like FLUX, FLUX.2, SD3, CogView4, Z-Image, or Anima.
+
+ 
+
+ ### Text Prompt Conditioning
+ Conditioning is necessary for the latent diffusion process, whether empty or not. As a result, the Denoising node requires positive and negative conditioning inputs. Conditioning is reliant on a CLIP text encoder provided by the Model Loader node.
+
+ 
+
+
+
+ ### Image to Latents & VAE
+ The **ImageToLatents** node takes in a pixel image and a VAE and outputs latents. The **LatentsToImage** node does the opposite, taking in latents and a VAE and outputs a pixel image.
+
+ 
+
+ ### Scaling
+ Use the **ImageScale**, **ScaleLatents**, and **Upscale** nodes to upscale images and/or latent images. Upscaling is the process of enlarging an image and adding more detail.
+
+ The chosen method differs across contexts. However, be aware that latents are already noisy and compressed at their original resolution; scaling an image could produce more detailed results.
+
+ 
+
+
+
+ ### ControlNet
+ The **ControlNet** node outputs a Control, which can be provided as input to a Denoise Latents node. Depending on the type of ControlNet desired, ControlNet nodes usually require an image processor node, such as a Canny Processor or Depth Processor, which prepares an input image for use with ControlNet.
+
+ 
+
+ ### LoRA
+ The **Lora Loader** node lets you load a LoRA and pass it as output. A LoRA provides fine-tunes to the UNet and text encoder weights that augment the base model’s image and text vocabularies.
+
+ 
+
+
+
+ ### Defined & Random Seeds
+ It is common to want to use both the same seed (for continuity) and random seeds (for variety). To define a seed, simply enter it into the **'Seed'** field on a noise node. Conversely, the **RandomInt** node generates a random integer between 'Low' and 'High', and can be used as input to the 'Seed' edge point on a noise node to randomize your seed.
+
+ 
+
+ ### Iteration + Multiple Images as Input
+ Iteration is a common concept in any processing, and means to repeat a process with given input. In nodes, you're able to use the **Iterate** node to iterate through collections usually gathered by the **Collect** node.
+
+ The Iterate node has many potential uses, from processing a collection of images one after another, to varying seeds across multiple image generations and more. This screenshot demonstrates how to collect several images and use them in an image generation workflow.
+
+ 
+
+ ### Batch / Multiple Image Generation
+ Batch or multiple image generation in the workflow editor is done using the **RandomRange** node. In this case, the 'Size' field represents the number of images to generate, meaning this example will generate 4 images.
+
+ As RandomRange produces a collection of integers, we need to add the Iterate node to iterate through the collection. This noise can then be fed to the Denoise Latents node for it to iterate through the denoising process with the different seeds provided.
+
+ 
+
+
diff --git a/docs/src/content/docs/features/Workflows/face-tools.mdx b/docs/src/content/docs/features/Workflows/face-tools.mdx
new file mode 100644
index 00000000000..95959d158fd
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/face-tools.mdx
@@ -0,0 +1,94 @@
+---
+title: Face Tools Nodes
+---
+
+The Face Tools nodes detect faces with MediaPipe and provide utilities for identifying, masking, and extracting faces in workflows. The current nodes are in the `segmentation` category and use version `1.2.2`.
+
+## FaceIdentifier
+
+**FaceIdentifier** outputs a copy of the input image with detected face IDs printed on each face. Use it first when you need to target a specific face with FaceMask or FaceOff.
+
+Face IDs are numbered from `0`. Detection order can change if the image changes, so run FaceIdentifier again after editing an image.
+
+### Inputs
+
+| Input | Description |
+| --- | --- |
+| Image | Image to face detect. |
+| Minimum Confidence | Minimum confidence for face detection. Lower this if detection is failing. |
+| Chunk | Bypass full-image face detection and use chunking. Chunking is also used automatically if no faces are found in the full image. |
+
+### Outputs
+
+| Output | Description |
+| --- | --- |
+| Image | The input image with face ID numbers drawn on detected faces. |
+| Width | Output image width in pixels. |
+| Height | Output image height in pixels. |
+
+## FaceMask
+
+**FaceMask** creates a mask for detected faces on the input image.
+
+Leave **Face IDs** empty to mask all detected faces, or provide a comma-separated list such as `0,2,7` to target specific faces. Use FaceIdentifier to find the IDs.
+
+The mask can be adjusted with X and Y offsets if detection is slightly too large or small. Enable **Invert Mask** to affect everything except the detected faces.
+
+### Inputs
+
+| Input | Description |
+| --- | --- |
+| Image | Image to face detect. |
+| Face IDs | Comma-separated list of face IDs to mask. Leave empty to mask all detected faces. |
+| Minimum Confidence | Minimum confidence for face detection. Lower this if detection is failing. |
+| X Offset | Offset for the X-axis of the face mask. |
+| Y Offset | Offset for the Y-axis of the face mask. |
+| Chunk | Bypass full-image face detection and use chunking. Chunking is also used automatically if no faces are found in the full image. |
+| Invert Mask | Toggle to invert the mask. |
+
+### Outputs
+
+| Output | Description |
+| --- | --- |
+| Image | The original image, converted to RGBA. |
+| Width | Output image width in pixels. |
+| Height | Output image height in pixels. |
+| Mask | The generated face mask. |
+
+## FaceOff
+
+**FaceOff** extracts a single detected face into a bounded image and returns a matching mask plus paste coordinates. Use FaceIdentifier to find the face ID before targeting a specific face.
+
+Padding expands the bounding box around the detected face. This gives downstream processing more context and increases the bounded image size while keeping the face in place within the crop.
+
+### Inputs
+
+| Input | Description |
+| --- | --- |
+| Image | Image for face detection. |
+| Face ID | The face ID to process, numbered from `0`. Multiple faces are not supported. |
+| Minimum Confidence | Minimum confidence for face detection. Lower this if detection is failing. |
+| X Offset | X-axis offset of the mask. |
+| Y Offset | Y-axis offset of the mask. |
+| Padding | All-axis padding around the mask in pixels. |
+| Chunk | Bypass full-image face detection and use chunking. Chunking is also used automatically if no faces are found in the full image. |
+
+### Outputs
+
+| Output | Description |
+| --- | --- |
+| Image | The bounded face image. If no face is found, the original image passes through. |
+| Width | Output image width in pixels. |
+| Height | Output image height in pixels. |
+| Mask | Mask matching the bounded image. |
+| X | X coordinate of the bounding box's left side. |
+| Y | Y coordinate of the bounding box's top side. |
+
+## Tips
+
+- Use the same **Minimum Confidence** value in FaceIdentifier and the FaceMask or FaceOff node that consumes the IDs.
+- Enable **Chunk** if not all target faces are detected. Full-image detection and chunked detection can produce different results.
+- Lower **Minimum Confidence** when detection fails, but watch for false positives.
+- Adjust X and Y offsets if the mask is too large, too small, or shifted.
+- Add FaceOff padding when the extracted face needs more surrounding context.
+- Face detection can fail on heavy face paint, hair covering the face, extreme angles, or other obstructions.
diff --git a/docs/src/content/docs/features/Workflows/index.mdx b/docs/src/content/docs/features/Workflows/index.mdx
new file mode 100644
index 00000000000..0ce8b49de82
--- /dev/null
+++ b/docs/src/content/docs/features/Workflows/index.mdx
@@ -0,0 +1,31 @@
+---
+title: Using Workflows
+sidebar:
+ order: 1
+---
+
+import { LinkCard, CardGrid } from '@astrojs/starlight/components';
+
+Workflows allow you to link multiple **Nodes** together to create custom, repeatable image generation processes. By connecting the outputs of some nodes to the inputs of others, you can build complex functionality tailored to your specific needs.
+
+## The Node Editor
+
+With nodes, you can easily extend the image generation capabilities of InvokeAI. All InvokeAI features are added through nodes.
+
+You can read more about nodes and how to use the node editor by checking out the detailed node documentation:
+
+
+
+## Downloading New Nodes
+
+To download a new node and enhance your workflows with new features, visit our list of Community Nodes. These are nodes that have been created by the community, for the community.
+
+
diff --git a/docs/assets/gallery/board_settings.png b/docs/src/content/docs/features/assets/board_settings.png
similarity index 100%
rename from docs/assets/gallery/board_settings.png
rename to docs/src/content/docs/features/assets/board_settings.png
diff --git a/docs/assets/gallery/board_tabs.png b/docs/src/content/docs/features/assets/board_tabs.png
similarity index 100%
rename from docs/assets/gallery/board_tabs.png
rename to docs/src/content/docs/features/assets/board_tabs.png
diff --git a/docs/assets/gallery/board_thumbnails.png b/docs/src/content/docs/features/assets/board_thumbnails.png
similarity index 100%
rename from docs/assets/gallery/board_thumbnails.png
rename to docs/src/content/docs/features/assets/board_thumbnails.png
diff --git a/docs/assets/gallery/gallery.png b/docs/src/content/docs/features/assets/gallery.png
similarity index 100%
rename from docs/assets/gallery/gallery.png
rename to docs/src/content/docs/features/assets/gallery.png
diff --git a/docs/assets/gallery/image_menu.png b/docs/src/content/docs/features/assets/image_menu.png
similarity index 100%
rename from docs/assets/gallery/image_menu.png
rename to docs/src/content/docs/features/assets/image_menu.png
diff --git a/docs/assets/gallery/info_button.png b/docs/src/content/docs/features/assets/info_button.png
similarity index 100%
rename from docs/assets/gallery/info_button.png
rename to docs/src/content/docs/features/assets/info_button.png
diff --git a/docs/assets/gallery/thumbnail_menu.png b/docs/src/content/docs/features/assets/thumbnail_menu.png
similarity index 100%
rename from docs/assets/gallery/thumbnail_menu.png
rename to docs/src/content/docs/features/assets/thumbnail_menu.png
diff --git a/docs/assets/gallery/top_controls.png b/docs/src/content/docs/features/assets/top_controls.png
similarity index 100%
rename from docs/assets/gallery/top_controls.png
rename to docs/src/content/docs/features/assets/top_controls.png
diff --git a/docs/src/content/docs/features/custom-node-manager.mdx b/docs/src/content/docs/features/custom-node-manager.mdx
new file mode 100644
index 00000000000..d91f4d82641
--- /dev/null
+++ b/docs/src/content/docs/features/custom-node-manager.mdx
@@ -0,0 +1,91 @@
+---
+title: Custom Node Manager
+lastUpdated: 2026-05-23
+sidebar:
+ order: 4
+---
+
+import { Steps } from '@astrojs/starlight/components'
+
+The Custom Node Manager allows you to install, manage, and remove community node packs directly from the InvokeAI UI — no manual file copying required.
+
+## Accessing the Node Manager
+
+Click the **Nodes** tab (circuit icon) in the left sidebar, between Models and Queue.
+
+## Installing a Node Pack
+
+
+ 1. Navigate to the **Nodes** tab
+ 2. On the right panel, select the **Git Repository URL** tab
+ 3. Paste the Git URL of the node pack (e.g. `https://github.com/user/my-node-pack.git`)
+ 4. Click **Install**
+
+
+The installer will:
+
+- Clone the repository into your `nodes` directory
+- Load the nodes immediately — no restart needed
+- Import any workflow `.json` files found in the repository into your workflow library (tagged with `node-pack:` for easy filtering)
+
+The install progress and results are shown in the **Install Log** at the bottom of the panel.
+
+### Installing Python Dependencies
+
+The installer does **not** automatically run `pip install` for `requirements.txt` or `pyproject.toml`. Auto-installing dependencies into the running InvokeAI environment can pull in incompatible package versions and break the application.
+
+If a node pack ships a `requirements.txt` or `pyproject.toml`, you'll see a warning toast after installation. Install the dependencies yourself by following the instructions in the node pack's documentation (typically `pip install -r requirements.txt` from inside an activated InvokeAI environment, but check the pack's README first). After installing, click the **Reload** button so the new dependencies take effect.
+
+### Security Warning
+
+Custom nodes execute arbitrary Python code on your system. **Only install node packs from authors you trust.** Malicious nodes could harm your system or compromise your data.
+
+## Managing Installed Nodes
+
+The left panel shows all installed node packs with:
+
+- **Pack name**
+- **Number of nodes** provided
+- **Individual node types** as badges
+- **File path** on disk
+
+### Reloading Nodes
+
+Click the **Reload** button to re-scan the nodes directory. This picks up any node packs that were manually added to the directory without using the installer.
+
+### Uninstalling a Node Pack
+
+Click the **Uninstall** button on any node pack. This will:
+
+- Remove the node pack directory
+- Unregister the nodes from the system immediately
+- Remove any workflows that were imported from the pack
+- Update the workflow editor so the nodes are no longer available
+
+No restart is required.
+
+## Scan Folder Tab
+
+The **Scan Folder** tab shows the location of your nodes directory. Node packs placed there manually (e.g. via `git clone`) are automatically detected at startup. Use the **Reload** button to detect newly added packs without restarting.
+
+## Troubleshooting
+
+### Node pack fails to install
+
+
+ 1. Verify the Git URL is correct and accessible
+ 2. Check that the repository contains an `__init__.py` file at the top level
+ 3. Review the Install Log for error details
+
+
+### Nodes don't appear after install
+
+
+ 1. Click the **Reload** button
+ 2. Check that the node pack's `__init__.py` imports its node classes
+ 3. Check the server console for error messages
+
+
+### Workflows show errors after uninstalling
+
+If you have user-created workflows that reference nodes from an uninstalled pack, those workflows will show errors for the missing node types. Reinstall the pack or remove the affected nodes from the workflow.
diff --git a/docs/src/content/docs/features/gallery.mdx b/docs/src/content/docs/features/gallery.mdx
new file mode 100644
index 00000000000..b48f3c19176
--- /dev/null
+++ b/docs/src/content/docs/features/gallery.mdx
@@ -0,0 +1,139 @@
+---
+title: Gallery Panel
+description: Learn how to manage, organize, and use your generated images and assets with the Gallery Panel in InvokeAI.
+lastUpdated: 2026-02-19
+sidebar:
+ order: 1
+---
+
+import { Card, CardGrid, Steps } from '@astrojs/starlight/components';
+
+The Gallery Panel is a fast way to review, find, and make use of images you've generated and loaded. The Gallery is divided into **Boards**. The *Uncategorized* board is always present, but you can create your own for better organization.
+
+
+
+---
+
+## Board Display and Settings
+
+At the very top of the Gallery Panel, you will find the board disclosure and settings buttons.
+
+
+
+The **disclosure button** shows the name of the currently selected board and allows you to toggle the visibility of the board thumbnails.
+
+
+
+The **settings button** opens a list of customization options:
+
+
+
+- **Image Size:** A slider that lets you control the size of the image previews in the gallery.
+- **Auto-Switch to New Images:** When enabled, newly generated images will automatically load into the current image panel (on the Text to Image tab) or the result panel (on the Image to Image tab). This happens invisibly even if you are on a different tab during generation.
+- **Auto-Assign Board on Click:** Whenever an image is generated or saved, it is placed into a board. The destination board is marked with an `AUTO` badge.
+ - *When enabled:* The board selected at the moment you click **Invoke** becomes the destination. This allows you to queue multiple generations into different boards without waiting for them to finish.
+ - *When disabled:* An **Auto-Add Board** dropdown appears, allowing you to set one specific board as the permanent destination for all new images.
+- **Always Show Image Size Badge:** Toggles whether the resolution (e.g., 512x512) is displayed on each image preview thumbnail.
+
+Below these buttons is the **Search Boards** text entry area, allowing you to quickly find specific boards by name. Next to it is the **Add Board (+)** button for creating new boards.
+
+:::tip
+You can rename any board by simply clicking on its name under the thumbnail and typing the new name.
+:::
+
+---
+
+## Board Management
+
+Each board has a context menu accessible via right-click (or Ctrl+click).
+
+
+
+- **Auto-add to this Board:** If *Auto-Assign Board on Click* is disabled in settings, use this option to quickly set the selected board as the default destination for new images.
+- **Download Board:** Packages all images within the board into a `.zip` file. A notification link will be provided when the download is ready.
+- **Delete Board:** Permanently removes the board and all of its contents.
+
+:::danger
+Deleting a board will **permanently delete all images** contained within it. Proceed with caution!
+:::
+
+### Board Contents
+
+Every board is organized into two distinct tabs:
+
+
+
+1. **Images:** Images generated directly within InvokeAI.
+2. **Assets:** External images you have uploaded to use as an [Image Prompt](https://support.invoke.ai/support/solutions/articles/151000159340-using-the-image-prompt-adapter-ip-adapter-) or within the Image to Image tab.
+
+---
+
+## Virtual Boards
+
+Virtual boards are read-only board groupings that Invoke computes on-the-fly from your image metadata rather than storing in the database. The first available type groups images **By Date**, creating one sub-board per day on which you generated images.
+
+Virtual boards are **off by default**. To enable them:
+
+1. Open the **board settings** (gear icon at the top of the Gallery).
+2. Toggle **Virtual Boards** on.
+3. A collapsible **By Date** section appears in the board list, with a sub-board for each day that has images. Each sub-board shows the date, image / asset counts, and a cover thumbnail.
+
+Selecting a date sub-board filters the gallery to just the images from that day. The collapse state of the By Date section persists across reloads.
+
+### Limits
+
+Because virtual boards are derived, not stored:
+
+- They are **read-only**: no drag-and-drop, no context menu, no auto-add destination.
+- You cannot rename or delete them.
+- Generating a new image updates the counts immediately, but the image is still saved to your regular auto-add board — virtual boards are a *view*, not a destination.
+- Disabling the **Virtual Boards** toggle hides the section and resets the selection to *Uncategorized* if you were viewing a virtual sub-board.
+
+---
+
+## Image Interaction
+
+Every image generated by InvokeAI stores its generation metadata (prompt, seed, models, etc.) directly inside the file. You can read this data by selecting the image and clicking the **Info button**  in any result panel.
+
+Additionally, each image has a context menu (right-click or Ctrl+click) with powerful workflow actions:
+
+
+
+*Options marked with an asterisk (\*) require the image to have generation metadata.*
+
+
+
+ - **Open in New Tab:** Opens the image in a separate browser tab.
+ - **Download Image:** Saves the image to your local device.
+ - **Star Image:** Pins the image to the top of the gallery. *(Also available by clicking the star icon on hover).*
+
+
+ - **Load Workflow*:** Loads the saved workflow settings into the Workflow tab and opens it.
+ - **Remix Image*:** Loads all generation settings (**excluding** the Seed) into the control panel.
+ - **Use Prompt*:** Loads only the text prompts.
+ - **Use Seed*:** Loads only the Seed.
+ - **Use All*:** Loads all generation settings into the control panel.
+
+
+ - **Send to Image to Image:** Moves the image to the left-hand panel of the Image to Image tab.
+ - **Send to Unified Canvas:** **Replaces** the current Unified Canvas contents with this image.
+
+
+ - **Change Board:** Opens a prompt to move the image. *(You can also drag and drop images onto board thumbnails).*
+ - **Delete Image:** Permanently deletes the image from InvokeAI.
+
+
+
+:::caution
+ Selecting **Delete Image** will remove the image entirely from your InvokeAI installation. This action cannot be undone.
+:::
+
+---
+
+## Summary
+
+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
+
+A huge shout-out to the core team working to make the Web GUI a reality, including [psychedelicious](https://github.com/psychedelicious), [Kyle0654](https://github.com/Kyle0654), and [blessedcoolant](https://github.com/blessedcoolant). [hipsterusername](https://github.com/hipsterusername) was the team's unofficial cheerleader and added tooltips/docs.
diff --git a/docs/src/content/docs/features/hotkeys.mdx b/docs/src/content/docs/features/hotkeys.mdx
new file mode 100644
index 00000000000..a4b99fca16d
--- /dev/null
+++ b/docs/src/content/docs/features/hotkeys.mdx
@@ -0,0 +1,202 @@
+---
+title: Hotkeys
+description: Learn how to use and customize hotkeys in InvokeAI, and how developers can interact with the hotkey system.
+lastUpdated: 2026-02-19
+sidebar:
+ order: 2
+---
+
+import { Tabs, TabItem, Steps, Card, CardGrid, Icon } from '@astrojs/starlight/components';
+
+InvokeAI allows you to customize all keyboard shortcuts (hotkeys) to match your workflow preferences. This guide covers how to use and customize hotkeys as a user, as well as providing technical documentation for developers.
+
+## User Guide
+
+
+
+ See all available keyboard shortcuts organized by category in one place.
+
+
+ Change any shortcut to your preference, or assign multiple key combinations to the same action.
+
+
+ Built-in validation prevents invalid combinations.
+
+
+ Your custom hotkeys are safely stored and restored across sessions.
+
+
+
+### Opening the Hotkeys Modal
+
+Press Shift + ? or click the **keyboard icon** in the application to open the Hotkeys Modal.
+
+### Managing Hotkeys
+
+
+
+ - Browse all available hotkeys organized by category (App, Canvas, Gallery, Workflows, etc.).
+ - Search for specific hotkeys using the search bar.
+ - See the current key combination for each action.
+
+
+
+ 1. Click the **pencil** button by the hotkey you want to change, or click the **plus** button to add a new one.
+ 2. Enter your new hotkey combination in the editor.
+ - Use modifier buttons for quick-insert (Mod , Ctrl , Shift , Alt ).
+ - Check the live preview to see how your hotkey will look.
+ 3. Click the **checkmark** or press Enter to save.
+
+
+
+ - **Reset a single hotkey:** Click the counter-clockwise arrow icon next to customized hotkeys.
+ - **Reset all hotkeys:** In Edit Mode, click the **Reset All to Default** button at the bottom.
+
+
+
+### Hotkey Format Reference
+
+When customizing hotkeys, use the following formats:
+
+- **Valid Modifiers:** `mod` (Ctrl on Windows/Linux, Cmd on Mac), `ctrl`, `shift`, `alt`
+- **Valid Keys:** Letters (`a-z`), Numbers (`0-9`), Function keys (`f1-f12`), Special keys (`enter`, `space`, `tab`, `backspace`, `delete`, `escape`), Arrow keys (`up`, `down`, `left`, `right`)
+- **Multiple alternatives:** Separate with commas (e.g., `mod+enter, ctrl+enter`)
+
+:::tip
+ **Valid Hotkeys:** `mod+s`, `ctrl+shift+p`, `f5, mod+r`
+ **Invalid Hotkeys:** `mod+` (no key after modifier), `shift+ctrl+` (ends with modifier)
+:::
+
+---
+
+## Developer Guide
+
+The hotkeys system allows developers to centrally define, manage, and validate hotkeys throughout the application. It is built on top of `react-hotkeys-hook`.
+
+### Architecture
+
+The customizable hotkeys feature comprises the following components:
+
+
+
+ **Hotkeys State Slice (`hotkeysSlice.ts`)**
+ - Stores custom hotkey mappings in Redux state.
+ - Persisted to IndexedDB using `redux-remember`.
+ - Provides actions to change, reset individual, or reset all hotkeys.
+
+
+ **`useHotkeyData` Hook (`useHotkeyData.ts`)**
+ - Defines all default hotkeys and merges them with custom hotkeys from the store.
+ - Returns the effective hotkeys to be used.
+ - Provides platform-specific key translations.
+
+
+ - **`HotkeyEditor.tsx`**: Inline editor with live preview, validation, and modifier insertion.
+ - **`HotkeysModal.tsx`**: The modal interface supporting View/Edit modes, searching, and categories.
+
+
+
+### Adding New Hotkeys
+
+To add a new hotkey to the system, follow these steps:
+
+
+1. **Add Translation Strings**
+ In `invokeai/frontend/web/public/locales/en.json`:
+ ```json
+ {
+ "hotkeys": {
+ "app": {
+ "myAction": {
+ "title": "My Action",
+ "desc": "Description of what this hotkey does"
+ }
+ }
+ }
+ }
+ ```
+
+2. **Register the Hotkey**
+ In `invokeai/frontend/web/src/features/system/components/HotkeysModal/useHotkeyData.ts`:
+ ```typescript
+ // Inside the appropriate category builder function
+ addHotkey('app', 'myAction', ['mod+k']); // Default binding
+ ```
+
+3. **Use the Hotkey in Components**
+ ```tsx
+ import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
+
+ const MyComponent = () => {
+ const handleAction = useCallback(() => {
+ // Your action here
+ }, []);
+
+ // Automatically uses custom hotkeys if configured
+ useRegisteredHotkeys({
+ id: 'myAction',
+ category: 'app', // 'app', 'canvas', 'viewer', 'gallery', 'workflows'
+ callback: handleAction,
+ options: { enabled: true, preventDefault: true },
+ dependencies: [handleAction]
+ });
+
+ // ...
+ };
+ ```
+
+
+### Common Patterns
+
+
+
+ Only enable hotkeys when certain conditions are met:
+ ```typescript
+ useRegisteredHotkeys({
+ id: 'save',
+ category: 'app',
+ callback: handleSave,
+ options: {
+ enabled: hasUnsavedChanges && !isLoading, // Only when valid
+ preventDefault: true
+ },
+ dependencies: [hasUnsavedChanges, isLoading, handleSave]
+ });
+ ```
+
+
+ Ensure hotkeys are only active when a specific region is focused:
+ ```tsx
+ import { useFocusRegion } from 'common/hooks/focus';
+
+ const MyComponent = () => {
+ const focusRegionRef = useFocusRegion('myRegion');
+
+ // Hotkey only works when this region has focus
+ useRegisteredHotkeys({
+ id: 'myAction',
+ category: 'app',
+ callback: handleAction,
+ options: { enabled: true }
+ });
+
+ return ...
;
+ };
+ ```
+
+
+ Provide multiple alternatives for the same action:
+ ```typescript
+ // In useHotkeyData.ts
+ addHotkey('canvas', 'redo', ['mod+shift+z', 'mod+y']); // Two alternatives
+ ```
+
+
+
+:::caution[Best Practices]
+- **Use `mod` instead of `ctrl`**: Automatically maps to Cmd on Mac, Ctrl elsewhere.
+- **Provide descriptive translations**: Help users understand what each hotkey does.
+- **Avoid conflicts**: Check existing hotkeys before adding new ones.
+- **Check enabled state**: Only activate hotkeys when the action is available.
+- **Use dependencies correctly**: Ensure callbacks are stable with `useCallback`.
+:::
diff --git a/docs/src/content/docs/features/prompt-tools.md b/docs/src/content/docs/features/prompt-tools.md
new file mode 100644
index 00000000000..45bfd96170a
--- /dev/null
+++ b/docs/src/content/docs/features/prompt-tools.md
@@ -0,0 +1,55 @@
+---
+title: LLM Prompt Tools
+sidebar:
+ order: 3
+lastUpdated: 2026-05-23
+---
+
+InvokeAI includes two built-in tools that use local language models to help you write better prompts. Both tools appear as small buttons in the top-right corner of the positive prompt area and are only visible when you have a compatible model installed.
+
+## Expand Prompt
+
+Takes your short prompt and expands it into a detailed, vivid description suitable for image generation.
+
+**How to use:**
+
+1. Type a brief prompt (e.g. "a cat in a garden")
+2. Click the sparkle button in the prompt area
+3. Select a Text LLM model from the dropdown
+4. Click **Expand**
+5. Your prompt is replaced with the expanded version
+
+**Compatible models:** Any HuggingFace model with a `ForCausalLM` architecture. Recommended options:
+
+| Model | Size | HuggingFace ID |
+|-------|------|----------------|
+| Qwen2.5 1.5B Instruct | ~3 GB | `Qwen/Qwen2.5-1.5B-Instruct` |
+| Phi-3 Mini Instruct | ~7.5 GB | `microsoft/Phi-3-mini-4k-instruct` |
+| TinyLlama Chat | ~2 GB | `TinyLlama/TinyLlama-1.1B-Chat-v1.0` |
+
+Install by pasting the HuggingFace ID into the Model Manager. The model is automatically detected as a **Text LLM** type.
+
+## Image to Prompt
+
+Upload an image and generate a descriptive prompt from it using a vision-language model.
+
+**How to use:**
+
+1. Click the image button in the prompt area
+2. Select a LLaVA OneVision model from the dropdown
+3. Click **Upload Image** and select an image
+4. Click **Generate Prompt**
+5. The generated description is set as your prompt
+
+**Compatible models:** LLaVA OneVision models (already supported by InvokeAI).
+
+## Undo
+
+Both tools overwrite your current prompt. You can undo this change:
+
+- Press **Ctrl+Z** (or **Cmd+Z** on macOS) in the prompt textarea within 30 seconds
+- The undo state is cleared when you start typing manually
+
+## Workflow Node
+
+A **Text LLM** node is also available in the workflow editor for use in automated pipelines. It accepts a prompt string and model selection as inputs and outputs the expanded text as a string.
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
new file mode 100644
index 00000000000..52de38f6faf
--- /dev/null
+++ b/docs/src/content/docs/index.mdx
@@ -0,0 +1,127 @@
+---
+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 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
+ link: start-here/installation
+ icon: right-arrow
+ variant: primary
+ - text: View on GitHub
+ link: https://github.com/invoke-ai/InvokeAI
+ icon: github
+ variant: minimal
+---
+
+import { Image } from 'astro:assets'
+import { Card, CardGrid, LinkButton } from '@astrojs/starlight/components';
+import DownloadOptions from '@components/DownloadOptions.astro';
+
+import splashImage from './assets/invoke-webui-canvas.png';
+
+
+
+
+
+## The Creative Engine
+
+Invoke provides the most feature-complete and professional toolkit for AI image generation, built with production workflows in mind.
+
+
+
+ Experience true **Layer-based Canvas Editing**. Invoke's powerful canvas allows you to draw, paint, sketch, and edit your creations with unhindered precision. Each layer can be independently manipulated—giving you the freedom to compose intricate scenes seamlessly without breaking a sweat.
+
+
+ Unlock limitless possibilities with **Advanced Node-based Workflows**. Build your own complex, reproducible pipelines via a completely visual graph backend. Expose custom UI parameters to share and update values easily without diving deep into the graph.
+
+
+ Stay on the cutting edge with out-of-the-box support for the latest foundational models, including **Flux, SDXL, SD 1.5**, and more. Manage checkpoints, LoRAs, Textual Inversions, and ControlNets with an intuitive Model Manager.
+
+
+ **Completely Local & Self-hosted**. Invoke runs locally on your own hardware. Your data, prompts, and creations belong entirely to you. Say goodbye to restrictive cloud services and privacy concerns—maintain absolute control over your art.
+
+
+
+---
+
+## Built for Production
+
+Invoke is designed to keep your creative flow moving. Unlike other tools that feel like engineering experiments, Invoke is a polished, professional-grade application.
+
+
+
+ A beautiful, clean interface that prioritizes your artwork. No cluttered menus—just the tools you need right where you expect them.
+
+
+ Extensive ControlNet implementation allows you to guide generations with depth maps, edges, poses, and more for exact composition control.
+
+
+ Rapidly iterate on concepts with batch generation, prompt wildcards, and high-resolution upscaling, all without leaving the app.
+
+
+ Actively developed by a passionate open-source community. Jump into the conversation, report bugs, or request features directly.
+
+
+
+---
+
+## Join the Ecosystem
+
+Whether you are looking to install the app, get support, train your own models, or contribute to the project, the Invoke community has you covered.
+
+
+
+ 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/).
+
+
+ Get Invoke
+
+
+
+
+ Want to train models on your own style? Invoke Training provides a dedicated UI for **Textual Inversion** and **LoRA training**.
+
+
+ Explore Invoke Training
+
+
+
+
+ Stuck? Check out our comprehensive [FAQ](/troubleshooting/faq/) for quick answers. If you still need a hand, our community is incredibly active and helpful.
+
+ Join our Discord
+
+
+
+ Invoke is open-source software made possible by [people across the world](/contributing/contributors/). We welcome code, documentation, and design contributions of any size! Read our [contributing guide](/contributing/) to start.
+
+
+ Read Contribution Guide
+
+
+
+
+---
+
+## Download Invoke
+
+Ready to unleash your creativity? Invoke is available for Windows, macOS, and Linux. Self-hosted, fully customizable, and Apache 2.0 licensed.
+
+
+
+---
+
+:::note[About the Hosted Version]
+The Invoke hosted platform has been shut down as the founding team joined Adobe. However, Invoke lives on as a thriving open-source project maintained by the community.
+
+The open-source version offers the same powerful features you may have used in the hosted service, with the added benefit of complete control and privacy through self-hosting.
+
+Stewardship of the project has been passed to Lincoln Stein (lstein) and Vic (Blessedcoolant), who have been core maintainers since the project's inception and continue to drive development forward with the community.
+:::
diff --git a/docs/src/content/docs/start-here/installation.mdx b/docs/src/content/docs/start-here/installation.mdx
new file mode 100644
index 00000000000..02e2680db66
--- /dev/null
+++ b/docs/src/content/docs/start-here/installation.mdx
@@ -0,0 +1,89 @@
+---
+title: Simple Installation
+lastUpdated: 2026-02-18
+---
+
+import { LinkCard, Tabs, TabItem, Steps } from '@astrojs/starlight/components'
+import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
+
+export const alternateLaunchers = [
+ {
+ title: 'Stability Matrix',
+ description: 'Get the latest version of Stability Matrix for your platform.',
+ href: 'https://github.com/LykosAI/StabilityMatrix'
+ },
+ {
+ title: 'LynxHub',
+ description: 'Get the latest version of LynxHub for your platform.',
+ href: 'https://github.com/KindaBrazy/LynxHub'
+ },
+]
+
+
+
+## Invoke Launcher
+
+The Invoke launcher is the official launcher to install, update and manage your invoke installation.
+
+### Download and Set Up the Launcher
+
+The Launcher manages your Invoke install. Follow these instructions to download and set up the Launcher.
+
+
+
+
+ 1. [Download for Windows]
+ 2. Run the `EXE` to install the Launcher and start it.
+ 3. A desktop shortcut will be created; use this to run the Launcher in the future.
+ 4. You can delete the `EXE` file you downloaded.
+
+
+
+
+ 1. [Download for MacOS]
+ 2. Open the `DMG` and drag the app into `Applications`.
+ 3. Run the launcher from `Applications`.
+ 4. You can delete the `DMG` file you downloaded.
+
+
+
+
+ 1. [Download for Linux]
+ 2. You may need to edit the `AppImage` file properties and make it executable.
+ 3. Optionally move the file to a location that does not require admin privileges and add a desktop shortcut for it.
+ 4. Run the Launcher by double-clicking the `AppImage` or the shortcut you made.
+
+
+
+
+### Install Invoke
+
+Run the Launcher you just set up if you haven't already. Click **Install** and follow the instructions to install (or update) Invoke.
+
+If you have an existing Invoke installation, you can select it and let the launcher manage the install. You'll be able to update or launch the installation.
+
+### Updating
+
+The Launcher will check for updates for itself _and_ Invoke.
+
+When the Launcher detects an update is available for itself, you'll get a small popup window. Click through this and the Launcher will update itself.
+
+When the Launcher detects an update for Invoke, you'll see a small green alert in the Launcher. Click that and follow the instructions to update Invoke.
+
+## Alternative Launchers
+
+:::caution
+ Installations from alternate launchers are not managed by Invoke, so we cannot guarantee it will work correctly. If you want a more stable experience, we recommend using the [official Invoke Launcher](#invoke-launcher).
+:::
+
+{alternateLaunchers.map(({title, description, href}) => (
+
+))}
+
+[Download for Windows]: https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition.Setup.latest.exe
+[Download for MacOS]: https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest-arm64.dmg
+[Download for Linux]: https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest.AppImage
diff --git a/docs/src/content/docs/start-here/manual.mdx b/docs/src/content/docs/start-here/manual.mdx
new file mode 100644
index 00000000000..cc7b45d5fe6
--- /dev/null
+++ b/docs/src/content/docs/start-here/manual.mdx
@@ -0,0 +1,193 @@
+---
+title: Manual Installation
+lastUpdated: 2026-02-18
+---
+
+import { LinkCard, Tabs, TabItem, Steps, LinkButton } from '@astrojs/starlight/components'
+import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
+
+
+
+## Are you in the right place?
+
+
+
+
+
+## Walkthrough
+
+We'll use [`uv`](https://github.com/astral-sh/uv) to install python and create a virtual environment, then install the `invokeai` package. `uv` is a modern, very fast alternative to `pip`.
+
+The following commands vary depending on the version of Invoke being installed and the system onto which it is being installed.
+
+
+
+ 1. Install `uv` as described in its [docs](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer). We suggest using the standalone installer method.
+
+ Run `uv --version` to confirm that `uv` is installed and working. After installation, you may need to restart your terminal to get access to `uv`.
+
+ 2. Create a directory for your installation, typically in your home directory (e.g. `~/invokeai` or `$Home/invokeai`):
+
+
+
+ ```ps
+ mkdir $Home/invokeai
+ cd $Home/invokeai
+ ```
+
+
+ ```bash
+ mkdir ~/invokeai
+ cd ~/invokeai
+ ```
+
+
+
+ 3. Create a virtual environment in that directory:
+
+ ```sh
+ uv venv --relocatable --prompt invoke --python 3.12 --python-preference only-managed .venv
+ ```
+
+ This command creates a portable virtual environment at `.venv` complete with a portable python 3.12. It doesn't matter if your system has no python installed, or has a different version - `uv` will handle everything.
+
+ 4. Activate the virtual environment:
+
+
+
+ ```ps
+ .venv\Scripts\activate
+ ```
+
+
+ ```bash
+ source .venv/bin/activate
+ ```
+
+
+
+ 5. Choose a version to install.
+
+
+ View Releases
+
+
+ 6. Determine the package specifier to use when installing. This is a performance optimization.
+
+ - If you have an Nvidia 20xx series GPU or older, use `invokeai[xformers]`.
+ - If you have an Nvidia 30xx series GPU or newer, or do not have an Nvidia GPU, use `invokeai`.
+
+ 7. Determine the torch backend to use for installation, if any. This is necessary to get the right version of torch installed. This is acheived by using [UV's built in torch support.](https://docs.astral.sh/uv/guides/integration/pytorch/#automatic-backend-selection)
+
+ :::note[Torch Backend Selection]
+ Pick a torch backend only when it applies to your system. In all other cases, do not use a torch backend.
+ :::
+
+
+
+
+
+ Use:
+ ```sh
+ --torch-backend=cu128
+ ```
+
+
+ Do not use a torch backend.
+
+
+
+
+
+
+
+ Use:
+ ```sh
+ --torch-backend=cu128
+ ```
+
+
+ Use:
+ ```sh
+ --torch-backend=cpu
+ ```
+
+
+ Use:
+ ```sh
+ --torch-backend=rocm7.1
+ ```
+
+
+ Do not use a torch backend.
+
+
+
+
+
+ 8. Install the `invokeai` package. Substitute the package specifier and version.
+
+
+
+ ```sh
+ uv pip install == --python 3.12 --python-preference only-managed --force-reinstall
+ ```
+
+
+ ```sh
+ uv pip install == --python 3.12 --python-preference only-managed --torch-backend= --force-reinstall
+ ```
+
+
+
+
+ 9. Deactivate and reactivate your venv so that the invokeai-specific commands become available in the environment:
+
+
+
+ ```ps
+ deactivate
+ .venv\Scripts\activate
+ ```
+
+
+ ```bash
+ deactivate && source .venv/bin/activate
+ ```
+
+
+
+ 10. Run the application, specifying the directory you created earlier as the root directory:
+
+
+
+ ```ps
+ invokeai-web --root ~/invokeai
+ ```
+
+
+ ```bash
+ invokeai-web --root $Home/invokeai
+ ```
+
+
+
+
+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](../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.
diff --git a/docs/src/content/docs/start-here/system-requirements.mdx b/docs/src/content/docs/start-here/system-requirements.mdx
new file mode 100644
index 00000000000..114698ce158
--- /dev/null
+++ b/docs/src/content/docs/start-here/system-requirements.mdx
@@ -0,0 +1,139 @@
+---
+title: Hardware Requirements
+sidebar:
+ order: 1
+lastUpdated: 2026-02-18
+---
+
+import { Tabs, TabItem, Steps } from '@astrojs/starlight/components'
+
+Invoke runs on Windows 10+, macOS 14+ and Linux (Ubuntu 20.04+ is well-tested).
+
+## Hardware
+
+Hardware requirements vary significantly depending on model and image output size.
+
+The requirements below are rough guidelines for best performance. GPUs with less VRAM typically still work, if a bit slower. Follow the [Low VRAM Guide] to optimize performance.
+
+- All Apple Silicon (M1, M2, etc) Macs work, but 16GB+ memory is recommended.
+- AMD GPUs are supported on Linux only. The VRAM requirements are the same as Nvidia GPUs.
+
+### Windows/Linux
+
+| Model Family | Best resolution | GPU (series) | VRAM (min) | RAM (min) | Notes |
+|---|---:|---|---:|---:|---|
+| SD1.5 | 512x512 | Nvidia 10xx+ | 4GB | 8GB | |
+| SDXL | 1024x1024 | Nvidia 20xx+ | 8GB | 16GB | |
+| FLUX.1 | 1024x1024 | Nvidia 20xx+ | 10GB | 32GB | |
+| FLUX.2 Klein 4B | 1024x1024 | Nvidia 30xx+ | 12GB | 16GB | FP8 works with 8GB+; Diffusers + encoder |
+| FLUX.2 Klein 9B | 1024x1024 | Nvidia 40xx | 24GB | 32GB | FP8 works with 12GB+; Diffusers + encoder |
+| Z-Image Turbo | 1024x1024 | Nvidia 20xx+ | 8GB | 16GB | Q4_K 8GB; Q8/BF16 16GB+ |
+
+:::tip[`tmpfs` on Linux]
+ If your temporary directory is mounted as a `tmpfs`, ensure it has sufficient space.
+:::
+
+## Python
+
+:::tip[The launcher installs python for you]
+ 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.
+
+Check that your system has an up-to-date Python installed by running `python3 --version` in the terminal (Linux, macOS) or cmd/powershell (Windows).
+
+:::tip[Installing Python]{icon="seti:python"}
+
+
+
+ 1. Install python with [an official installer].
+ 2. The installer includes an option to add python to your PATH. Be sure to enable this. If you missed it, re-run the installer, choose to modify an existing installation, and tick that checkbox.
+ 3. You may need to install [Microsoft Visual C++ Redistributable].
+
+
+
+
+ 1. Install python with [an official installer].
+ 2. If model installs fail with a certificate error, you may need to run this command (changing the python version to match what you have installed): `/Applications/Python\ 3.11/Install\ Certificates.command`
+ 3. If you haven't already, you will need to install the XCode CLI Tools by running `xcode-select --install` in a terminal.
+
+
+
+
+ 1. Installing python varies depending on your system. We recommend [using `uv` to manage your python installation].
+ 2. You'll need to install `libglib2.0-0` and `libgl1-mesa-glx` for OpenCV to work. For example, on a Debian system: `sudo apt update && sudo apt install -y libglib2.0-0 libgl1-mesa-glx`
+
+
+
+:::
+
+## Drivers
+
+If you have an Nvidia or AMD GPU, you may need to manually install drivers or other support packages for things to work well or at all.
+
+### Nvidia
+
+Run `nvidia-smi` on your system's command line to verify that drivers and CUDA are installed. If this command fails, or doesn't report versions, you will need to install drivers.
+
+Go to the [CUDA Toolkit Downloads] and carefully follow the instructions for your system to get everything installed.
+
+Confirm that `nvidia-smi` displays driver and CUDA versions after installation.
+
+#### Linux - via Nvidia Container Runtime
+
+An alternative to installing CUDA locally is to use the [Nvidia Container Runtime] to run the application in a container.
+
+#### Windows - Nvidia cuDNN DLLs
+
+An out-of-date cuDNN library can greatly hamper performance on 30-series and 40-series cards. Check with the community on discord to compare your `it/s` if you think you may need this fix.
+
+First, locate the destination for the DLL files and make a quick back up:
+
+1. Find your InvokeAI installation folder, e.g. `C:\Users\Username\InvokeAI\`.
+1. Open the `.venv` folder, e.g. `C:\Users\Username\InvokeAI\.venv` (you may need to show hidden files to see it).
+1. Navigate deeper to the `torch` package, e.g. `C:\Users\Username\InvokeAI\.venv\Lib\site-packages\torch`.
+1. Copy the `lib` folder inside `torch` and back it up somewhere.
+
+Next, download and copy the updated cuDNN DLLs:
+
+1. Go to the [Cuda Docs].
+1. Create an account if needed and log in.
+1. Choose the newest version of cuDNN that works with your GPU architecture. Consult the [cuDNN support matrix] to determine the correct version for your GPU.
+1. Download the latest version and extract it.
+1. Find the `bin` folder, e.g. `cudnn-windows-x86_64-SOME_VERSION\bin`.
+1. Copy and paste the `.dll` files into the `lib` folder you located earlier. Replace files when prompted.
+
+If, after restarting the app, this doesn't improve your performance, either restore your back up or re-run the installer to reset `torch` back to its original state.
+
+### AMD
+
+:::tip[Linux Only]{icon="linux"}
+ AMD GPUs are supported on Linux only, due to ROCm (the AMD equivalent of CUDA) support being Linux only.
+:::
+
+:::caution[Bumps Ahead]
+ While the application does run on AMD GPUs, there are occasional bumps related to spotty torch support.
+:::
+
+Run `rocm-smi` on your system's command line verify that drivers and ROCm are installed. If this command fails, or doesn't report versions, you will need to install them.
+
+Go to the [ROCm Documentation] and carefully follow the instructions for your system to get everything installed.
+
+Confirm that `rocm-smi` displays driver and CUDA versions after installation.
+
+#### Linux - via Docker Container
+
+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
+[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]: ../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
+[ROCm Documentation]: https://rocmdocs.amd.com
+[ROCm docker container]: https://rocmdocs.amd.com/en/latest/Deep_learning/Deep_learning.html#docker-containers
diff --git a/docs/src/content/docs/troubleshooting/faq.mdx b/docs/src/content/docs/troubleshooting/faq.mdx
new file mode 100644
index 00000000000..3c33845f995
--- /dev/null
+++ b/docs/src/content/docs/troubleshooting/faq.mdx
@@ -0,0 +1,117 @@
+---
+title: FAQ
+lastUpdated: 2026-02-19
+---
+
+import { LinkCard } from '@astrojs/starlight/components';
+
+If the troubleshooting steps on this page don't get you up and running, please either [create an issue] or hop on [discord] for help.
+
+## How to Install
+
+
+
+## Downloading models and using existing models
+
+The Model Manager tab in the UI provides a few ways to install models, including using your already-downloaded models. You'll see a popup directing you there on first startup. For more information, see the [model install docs].
+
+## Missing models after updating from v3
+
+If you find some models are missing after updating from v3, it's likely they weren't correctly registered before the update and didn't get picked up in the migration.
+
+You can use the `Scan Folder` tab in the Model Manager UI to fix this. The models will either be in the old, now-unused `autoimport` folder, or your `models` folder.
+
+- Find and copy your install's old `autoimport` folder path, install the main install folder.
+- Go to the Model Manager and click `Scan Folder`.
+- Paste the path and scan.
+- IMPORTANT: Uncheck `Inplace install`.
+- Click `Install All` to install all found models, or just install the models you want.
+
+Next, find and copy your install's `models` folder path (this could be your custom models folder path, or the `models` folder inside the main install folder).
+
+Follow the same steps to scan and import the missing models.
+
+## Slow generation
+
+- Check the [system requirements] to ensure that your system is capable of generating images.
+- Follow the [Low VRAM mode guide] to optimize performance.
+- Check that your generations are happening on your GPU (if you have one). Invoke will log what is being used for generation upon startup. If your GPU isn't used, re-install to and ensure you select the appropriate GPU option.
+- If you are on Windows with an Nvidia GPU, you may have exceeded your GPU's VRAM capacity and are triggering Nvidia's "sysmem fallback". There's a guide to opt out of this behaviour in the [Low VRAM mode guide].
+
+## Triton error on startup
+
+This can be safely ignored. Invoke doesn't use Triton, but if you are on Linux and wish to dismiss the error, you can install Triton.
+
+## Unable to Copy on Firefox
+
+Firefox does not allow Invoke to directly access the clipboard by default. As a result, you may be unable to use certain copy functions. You can fix this by configuring Firefox to allow access to write to the clipboard:
+
+- Go to `about:config` and click the Accept button
+- Search for `dom.events.asyncClipboard.clipboardItem`
+- Set it to `true` by clicking the toggle button
+- Restart Firefox
+
+## Replicate image found online
+
+Most example images with prompts that you'll find on the internet have been generated using different software, so you can't expect to get identical results. In order to reproduce an image, you need to replicate the exact settings and processing steps, including (but not limited to) the model, the positive and negative prompts, the seed, the sampler, the exact image size, any upscaling steps, etc.
+
+## Invalid configuration file
+
+Everything seems to install ok, you get a `ValidationError` when starting up the app.
+
+This is caused by an invalid setting in the `invokeai.yaml` configuration file. The error message should tell you what is wrong.
+
+Check the [configuration docs] for more detail about the settings and how to specify them.
+
+## Out of Memory Errors
+
+The models are large, VRAM is expensive, and you may find yourself faced with Out of Memory errors when generating images. Follow our [Low VRAM mode guide] to configure Invoke to prevent these.
+
+## Memory Leak (Linux)
+
+If you notice a memory leak, it could be caused to memory fragmentation as models are loaded and/or moved from CPU to GPU.
+
+A workaround is to tune memory allocation with an environment variable:
+
+```bash
+# Force blocks >1MB to be allocated with `mmap` so that they are released to the system immediately when they are freed.
+MALLOC_MMAP_THRESHOLD_=1048576
+```
+
+:::caution[Speed vs Memory Tradeoff]
+ Your generations may be slower overall when setting this environment variable.
+:::
+
+:::note[Possibly dependent on `libc` implementation]
+ It's not known if this issue occurs with other `libc` implementations such as `musl`.
+
+ If you encounter this issue and your system uses a different implementation, please try this environment variable and let us know if it fixes the issue.
+:::
+
+Detailed Discussion
+
+Python (and PyTorch) relies on the memory allocator from the C Standard Library (`libc`). On linux, with the GNU C Standard Library implementation (`glibc`), our memory access patterns have been observed to cause severe memory fragmentation.
+
+This fragmentation results in large amounts of memory that has been freed but can't be released back to the OS. Loading models from disk and moving them between CPU/CUDA seem to be the operations that contribute most to the fragmentation.
+
+This memory fragmentation issue can result in OOM crashes during frequent model switching, even if `ram` (the max RAM cache size) is set to a reasonable value (e.g. a OOM crash with `ram=16` on a system with 32GB of RAM).
+
+This problem may also exist on other OSes, and other `libc` implementations. But, at the time of writing, it has only been investigated on linux with `glibc`.
+
+To better understand how the `glibc` memory allocator works, see these references:
+
+- Basics: [The GNU Allocator](https://www.gnu.org/software/libc/manual/html_node/The-GNU-Allocator.html)
+- Details: [Malloc Internals](https://sourceware.org/glibc/wiki/MallocInternals)
+
+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
+[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
+[discord]: https://discord.gg/ZmtBAhwWhy
+[configuration docs]: ../../configuration/invokeai-yaml
diff --git a/docs/src/content/i18n/en.json b/docs/src/content/i18n/en.json
new file mode 100644
index 00000000000..69333e3a0b2
--- /dev/null
+++ b/docs/src/content/i18n/en.json
@@ -0,0 +1,45 @@
+{
+ "skipLink.label": "Skip to content",
+ "search.label": "Search",
+ "search.ctrlKey": "Ctrl",
+ "search.cancelLabel": "Cancel",
+ "search.devWarning": "Search is only available in production builds. \nTry building and previewing the site to test it out locally.",
+ "themeSelect.accessibleLabel": "Select theme",
+ "themeSelect.dark": "Dark",
+ "themeSelect.light": "Light",
+ "themeSelect.auto": "Auto",
+ "languageSelect.accessibleLabel": "Select language",
+ "menuButton.accessibleLabel": "Menu",
+ "sidebarNav.accessibleLabel": "Main",
+ "tableOfContents.onThisPage": "On this page",
+ "tableOfContents.overview": "Overview",
+ "i18n.untranslatedContent": "This content is not available in your language yet.",
+ "page.editLink": "Edit page",
+ "page.lastUpdated": "Last updated:",
+ "page.previousLink": "Previous",
+ "page.nextLink": "Next",
+ "page.draft": "This content is a draft and will not be included in production builds.",
+ "404.text": "Page not found. Check the URL or try using the search bar.",
+ "aside.note": "Note",
+ "aside.tip": "Tip",
+ "aside.caution": "Caution",
+ "aside.danger": "Danger",
+ "fileTree.directory": "Directory",
+ "builtWithStarlight.label": "Built with Starlight",
+ "heading.anchorLabel": "Section titled “{{title}}”",
+
+ "expressiveCode.copyButtonCopied": "Copied!",
+ "expressiveCode.copyButtonTooltip": "Copy to clipboard",
+ "expressiveCode.terminalWindowFallbackTitle": "Terminal window",
+
+ "pagefind.clear_search": "Clear",
+ "pagefind.load_more": "Load more results",
+ "pagefind.search_label": "Search this site",
+ "pagefind.filters_label": "Filters",
+ "pagefind.zero_results": "No results for [SEARCH_TERM]",
+ "pagefind.many_results": "[COUNT] results for [SEARCH_TERM]",
+ "pagefind.one_result": "[COUNT] result for [SEARCH_TERM]",
+ "pagefind.alt_search": "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead",
+ "pagefind.search_suggestion": "No results for [SEARCH_TERM]. Try one of the following searches:",
+ "pagefind.searching": "Searching for [SEARCH_TERM]..."
+}
diff --git a/docs/src/generated/invocation-context.json b/docs/src/generated/invocation-context.json
new file mode 100644
index 00000000000..55116ec69dd
--- /dev/null
+++ b/docs/src/generated/invocation-context.json
@@ -0,0 +1,657 @@
+{
+ "description": "Provides access to various services and data for the current invocation.\n\nAttributes:\n images (ImagesInterface): Methods to save, get and update images and their metadata.\n tensors (TensorsInterface): Methods to save and get tensors, including image, noise, masks, and masked images.\n conditioning (ConditioningInterface): Methods to save and get conditioning data.\n models (ModelsInterface): Methods to check if a model exists, get a model, and get a model's info.\n logger (LoggerInterface): The app logger.\n config (ConfigInterface): The app config.\n util (UtilInterface): Utility methods, including a method to check if an invocation was canceled and step callbacks.\n boards (BoardsInterface): Methods to interact with boards.",
+ "interfaces": [
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Gets an image as an ImageDTO object.",
+ "name": "get_dto",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the image to get.",
+ "name": "image_name",
+ "type": "str"
+ }
+ ],
+ "return_type": "ImageDTO",
+ "returns": "The image as an ImageDTO object.",
+ "signature": "(image_name: str) -> ImageDTO"
+ },
+ {
+ "description": "Gets an image's metadata, if it has any.",
+ "name": "get_metadata",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the image to get the metadata for.",
+ "name": "image_name",
+ "type": "str"
+ }
+ ],
+ "return_type": "Optional[MetadataField]",
+ "returns": "The image's metadata, if it has any.",
+ "signature": "(image_name: str) -> Optional[MetadataField]"
+ },
+ {
+ "description": "Gets the internal path to an image or thumbnail.",
+ "name": "get_path",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the image to get the path of.",
+ "name": "image_name",
+ "type": "str"
+ },
+ {
+ "default": "False",
+ "description": "Get the path of the thumbnail instead of the full image",
+ "name": "thumbnail",
+ "type": "bool"
+ }
+ ],
+ "return_type": "Path",
+ "returns": "The local path of the image or thumbnail.",
+ "signature": "(image_name: str, thumbnail: bool = False) -> Path"
+ },
+ {
+ "description": "Gets an image as a PIL Image object. This method returns a copy of the image.",
+ "name": "get_pil",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the image to get.",
+ "name": "image_name",
+ "type": "str"
+ },
+ {
+ "default": "None",
+ "description": "The color mode to convert the image to. If None, the original mode is used.",
+ "name": "mode",
+ "type": "Optional[Literal['L', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F']]"
+ }
+ ],
+ "return_type": "Image",
+ "returns": "The image as a PIL Image object.",
+ "signature": "(image_name: str, mode: Optional[Literal['L', 'RGB', 'RGBA', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F']] = None) -> Image"
+ },
+ {
+ "description": "Saves an image, returning its DTO.\nIf the current queue item has a workflow or metadata, it is automatically saved with the image.",
+ "name": "save",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The image to save, as a PIL image.",
+ "name": "image",
+ "type": "Image"
+ },
+ {
+ "default": "None",
+ "description": "The board ID to add the image to, if it should be added. It the invocation inherits from `WithBoard`, that board will be used automatically. **Use this only if you want to override or provide a board manually!**",
+ "name": "board_id",
+ "type": "Optional[str]"
+ },
+ {
+ "default": "ImageCategory.GENERAL",
+ "description": "The category of the image. Only the GENERAL category is added to the gallery.",
+ "name": "image_category",
+ "type": "ImageCategory"
+ },
+ {
+ "default": "None",
+ "description": "The metadata to save with the image, if it should have any. If the invocation inherits from `WithMetadata`, that metadata will be used automatically. **Use this only if you want to override or provide metadata manually!**",
+ "name": "metadata",
+ "type": "Optional[MetadataField]"
+ }
+ ],
+ "return_type": "ImageDTO",
+ "returns": "The saved image DTO.",
+ "signature": "(image: Image, board_id: Optional[str] = None, image_category: ImageCategory = ImageCategory.GENERAL, metadata: Optional[MetadataField] = None) -> ImageDTO"
+ }
+ ],
+ "name": "ImagesInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Loads a tensor by name. This method returns a copy of the tensor.",
+ "name": "load",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the tensor to load.",
+ "name": "name",
+ "type": "str"
+ }
+ ],
+ "return_type": "Tensor",
+ "returns": "The tensor.",
+ "signature": "(name: str) -> Tensor"
+ },
+ {
+ "description": "Saves a tensor, returning its name.",
+ "name": "save",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The tensor to save.",
+ "name": "tensor",
+ "type": "Tensor"
+ }
+ ],
+ "return_type": "str",
+ "returns": "The name of the saved tensor.",
+ "signature": "(tensor: Tensor) -> str"
+ }
+ ],
+ "name": "TensorsInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Loads conditioning data by name. This method returns a copy of the conditioning data.",
+ "name": "load",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the conditioning data to load.",
+ "name": "name",
+ "type": "str"
+ }
+ ],
+ "return_type": "ConditioningFieldData",
+ "returns": "The conditioning data.",
+ "signature": "(name: str) -> ConditioningFieldData"
+ },
+ {
+ "description": "Saves a conditioning data object, returning its name.",
+ "name": "save",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The conditioning data to save.",
+ "name": "conditioning_data",
+ "type": "ConditioningFieldData"
+ }
+ ],
+ "return_type": "str",
+ "returns": "The name of the saved conditioning data.",
+ "signature": "(conditioning_data: ConditioningFieldData) -> str"
+ }
+ ],
+ "name": "ConditioningInterface"
+ },
+ {
+ "description": "Common API for loading, downloading and managing models.",
+ "methods": [
+ {
+ "description": "Download the model file located at source to the models cache and return its Path.\nThis can be used to single-file install models and other resources of arbitrary types\nwhich should not get registered with the database. If the model is already\ninstalled, the cached path will be returned. Otherwise it will be downloaded.",
+ "name": "download_and_cache_model",
+ "parameters": [
+ {
+ "default": "",
+ "description": "A URL that points to the model, or a huggingface repo_id.",
+ "name": "source",
+ "type": "str | AnyHttpUrl"
+ }
+ ],
+ "return_type": "Path",
+ "returns": "Path to the downloaded model",
+ "signature": "(source: str | AnyHttpUrl) -> Path"
+ },
+ {
+ "description": "Check if a model exists.",
+ "name": "exists",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The key or ModelField representing the model.",
+ "name": "identifier",
+ "type": "Union[str, ModelIdentifierField]"
+ }
+ ],
+ "return_type": "bool",
+ "returns": "True if the model exists, False if not.",
+ "signature": "(identifier: Union[str, ModelIdentifierField]) -> bool"
+ },
+ {
+ "description": "Gets the absolute path for a given model config or path.\nFor example, if the model's path is `flux/main/FLUX Dev.safetensors`, and the models path is\n`/home/username/InvokeAI/models`, this method will return\n`/home/username/InvokeAI/models/flux/main/FLUX Dev.safetensors`.",
+ "name": "get_absolute_path",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The model config or path.",
+ "name": "config_or_path",
+ "type": "Union[AnyModelConfig, Path, str]"
+ }
+ ],
+ "return_type": "Path",
+ "returns": "The absolute path to the model.",
+ "signature": "(config_or_path: Union[AnyModelConfig, Path, str]) -> Path"
+ },
+ {
+ "description": "Get a model's config.",
+ "name": "get_config",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The key or ModelField representing the model.",
+ "name": "identifier",
+ "type": "Union[str, ModelIdentifierField]"
+ }
+ ],
+ "return_type": "AnyModelConfig",
+ "returns": "The model's config.",
+ "signature": "(identifier: Union[str, ModelIdentifierField]) -> AnyModelConfig"
+ },
+ {
+ "description": "Load a model.",
+ "name": "load",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The key or ModelField representing the model.",
+ "name": "identifier",
+ "type": "Union[str, ModelIdentifierField]"
+ },
+ {
+ "default": "None",
+ "description": "The submodel of the model to get.",
+ "name": "submodel_type",
+ "type": "Optional[SubModelType]"
+ }
+ ],
+ "return_type": "LoadedModel",
+ "returns": "An object representing the loaded model.",
+ "signature": "(identifier: Union[str, ModelIdentifierField], submodel_type: Optional[SubModelType] = None) -> LoadedModel"
+ },
+ {
+ "description": "Load a model by its attributes.",
+ "name": "load_by_attrs",
+ "parameters": [
+ {
+ "default": "",
+ "description": "Name of the model.",
+ "name": "name",
+ "type": "str"
+ },
+ {
+ "default": "",
+ "description": "The models' base type, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc.",
+ "name": "base",
+ "type": "BaseModelType"
+ },
+ {
+ "default": "",
+ "description": "Type of the model, e.g. `ModelType.Main`, `ModelType.Vae`, etc.",
+ "name": "type",
+ "type": "ModelType"
+ },
+ {
+ "default": "None",
+ "description": "The type of submodel to load, e.g. `SubModelType.UNet`, `SubModelType.TextEncoder`, etc. Only main models have submodels.",
+ "name": "submodel_type",
+ "type": "Optional[SubModelType]"
+ }
+ ],
+ "return_type": "LoadedModel",
+ "returns": "An object representing the loaded model.",
+ "signature": "(name: str, base: BaseModelType, type: ModelType, submodel_type: Optional[SubModelType] = None) -> LoadedModel"
+ },
+ {
+ "description": "Load the model file located at the indicated path\nIf a loader callable is provided, it will be invoked to load the model. Otherwise,\n`safetensors.torch.load_file()` or `torch.load()` will be called to load the model.\nBe aware that the LoadedModelWithoutConfig object has no `config` attribute",
+ "name": "load_local_model",
+ "parameters": [
+ {
+ "default": "",
+ "description": "",
+ "name": "model_path",
+ "type": "Path"
+ },
+ {
+ "default": "None",
+ "description": "A Callable that expects a Path and returns a dict[str|int, Any]",
+ "name": "loader",
+ "type": "Optional[Callable[[Path], AnyModel]]"
+ }
+ ],
+ "return_type": "LoadedModelWithoutConfig",
+ "returns": "A LoadedModelWithoutConfig object.",
+ "signature": "(model_path: Path, loader: Optional[Callable[[Path], AnyModel]] = None) -> LoadedModelWithoutConfig"
+ },
+ {
+ "description": "Download, cache, and load the model file located at the indicated URL or repo_id.\nIf the model is already downloaded, it will be loaded from the cache.\nIf the a loader callable is provided, it will be invoked to load the model. Otherwise,\n`safetensors.torch.load_file()` or `torch.load()` will be called to load the model.\nBe aware that the LoadedModelWithoutConfig object has no `config` attribute",
+ "name": "load_remote_model",
+ "parameters": [
+ {
+ "default": "",
+ "description": "A URL or huggingface repoid.",
+ "name": "source",
+ "type": "str | AnyHttpUrl"
+ },
+ {
+ "default": "None",
+ "description": "A Callable that expects a Path and returns a dict[str|int, Any]",
+ "name": "loader",
+ "type": "Optional[Callable[[Path], AnyModel]]"
+ }
+ ],
+ "return_type": "LoadedModelWithoutConfig",
+ "returns": "A LoadedModelWithoutConfig object.",
+ "signature": "(source: str | AnyHttpUrl, loader: Optional[Callable[[Path], AnyModel]] = None) -> LoadedModelWithoutConfig"
+ },
+ {
+ "description": "Search for models by attributes.",
+ "name": "search_by_attrs",
+ "parameters": [
+ {
+ "default": "None",
+ "description": "The name to search for (exact match).",
+ "name": "name",
+ "type": "Optional[str]"
+ },
+ {
+ "default": "None",
+ "description": "The base to search for, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc.",
+ "name": "base",
+ "type": "Optional[BaseModelType]"
+ },
+ {
+ "default": "None",
+ "description": "Type type of model to search for, e.g. `ModelType.Main`, `ModelType.Vae`, etc.",
+ "name": "type",
+ "type": "Optional[ModelType]"
+ },
+ {
+ "default": "None",
+ "description": "The format of model to search for, e.g. `ModelFormat.Checkpoint`, `ModelFormat.Diffusers`, etc.",
+ "name": "format",
+ "type": "Optional[ModelFormat]"
+ }
+ ],
+ "return_type": "list[AnyModelConfig]",
+ "returns": "A list of models that match the attributes.",
+ "signature": "(name: Optional[str] = None, base: Optional[BaseModelType] = None, type: Optional[ModelType] = None, format: Optional[ModelFormat] = None) -> list[AnyModelConfig]"
+ },
+ {
+ "description": "Search for models by path.",
+ "name": "search_by_path",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The path to search for.",
+ "name": "path",
+ "type": "Path"
+ }
+ ],
+ "return_type": "list[AnyModelConfig]",
+ "returns": "A list of models that match the path.",
+ "signature": "(path: Path) -> list[AnyModelConfig]"
+ }
+ ],
+ "name": "ModelsInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Logs a debug message.",
+ "name": "debug",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The message to log.",
+ "name": "message",
+ "type": "str"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(message: str) -> None"
+ },
+ {
+ "description": "Logs an error message.",
+ "name": "error",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The message to log.",
+ "name": "message",
+ "type": "str"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(message: str) -> None"
+ },
+ {
+ "description": "Logs an info message.",
+ "name": "info",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The message to log.",
+ "name": "message",
+ "type": "str"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(message: str) -> None"
+ },
+ {
+ "description": "Logs a warning message.",
+ "name": "warning",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The message to log.",
+ "name": "message",
+ "type": "str"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(message: str) -> None"
+ }
+ ],
+ "name": "LoggerInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Gets the app's config.",
+ "name": "get",
+ "parameters": [],
+ "return_type": "InvokeAIAppConfig",
+ "returns": "The app's config.",
+ "signature": "() -> InvokeAIAppConfig"
+ }
+ ],
+ "name": "ConfigInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "The step callback for FLUX.2 Klein models (32-channel VAE).",
+ "name": "flux2_step_callback",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The intermediate state of the diffusion pipeline.",
+ "name": "intermediate_state",
+ "type": "PipelineIntermediateState"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(intermediate_state: PipelineIntermediateState) -> None"
+ },
+ {
+ "description": "The step callback emits a progress event with the current step, the total number of\nsteps, a preview image, and some other internal metadata.\nThis should be called after each denoising step.",
+ "name": "flux_step_callback",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The intermediate state of the diffusion pipeline.",
+ "name": "intermediate_state",
+ "type": "PipelineIntermediateState"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(intermediate_state: PipelineIntermediateState) -> None"
+ },
+ {
+ "description": "Checks if the current session has been canceled.",
+ "name": "is_canceled",
+ "parameters": [],
+ "return_type": "bool",
+ "returns": "True if the current session has been canceled, False if not.",
+ "signature": "() -> bool"
+ },
+ {
+ "description": "The step callback emits a progress event with the current step, the total number of\nsteps, a preview image, and some other internal metadata.\nThis should be called after each denoising step.",
+ "name": "sd_step_callback",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The intermediate state of the diffusion pipeline.",
+ "name": "intermediate_state",
+ "type": "PipelineIntermediateState"
+ },
+ {
+ "default": "",
+ "description": "The base model for the current denoising step.",
+ "name": "base_model",
+ "type": "BaseModelType"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(intermediate_state: PipelineIntermediateState, base_model: BaseModelType) -> None"
+ },
+ {
+ "description": "Signals the progress of some long-running invocation. The progress is displayed in the UI.\nIf a percentage is provided, the UI will display a progress bar and automatically append the percentage to the\nmessage. You should not include the percentage in the message.\nExample:\n```py\ntotal_steps = 10\nfor i in range(total_steps):\npercentage = i / (total_steps - 1)\ncontext.util.signal_progress(\"Doing something cool\", percentage)\n```\nIf an image is provided, the UI will display it. If your image should be displayed at a different size, provide\na tuple of `(width, height)` for the `image_size` parameter. The image will be displayed at the specified size\nin the UI.\nFor example, SD denoising progress images are 1/8 the size of the original image, so you'd do this to ensure the\nimage is displayed at the correct size:\n```py\n# Calculate the output size of the image (8x the progress image's size)\nwidth = progress_image.width * 8\nheight = progress_image.height * 8\n# Signal the progress with the image and output size\nsignal_progress(\"Denoising\", percentage, progress_image, (width, height))\n```\nIf your progress image is very large, consider downscaling it to reduce the payload size and provide the original\nsize to the `image_size` parameter. The PIL `thumbnail` method is useful for this, as it maintains the aspect\nratio of the image:\n```py\n# `thumbnail` modifies the image in-place, so we need to first make a copy\nthumbnail_image = progress_image.copy()\n# Resize the image to a maximum of 256x256 pixels, maintaining the aspect ratio\nthumbnail_image.thumbnail((256, 256))\n# Signal the progress with the thumbnail, passing the original size\nsignal_progress(\"Denoising\", percentage, thumbnail, progress_image.size)\n```",
+ "name": "signal_progress",
+ "parameters": [
+ {
+ "default": "",
+ "description": "A message describing the current status. Do not include the percentage in this message.",
+ "name": "message",
+ "type": "str"
+ },
+ {
+ "default": "None",
+ "description": "The current percentage completion for the process. Omit for indeterminate progress.",
+ "name": "percentage",
+ "type": "float | None"
+ },
+ {
+ "default": "None",
+ "description": "An optional image to display.",
+ "name": "image",
+ "type": "Image | None"
+ },
+ {
+ "default": "None",
+ "description": "The optional size of the image to display. If omitted, the image will be displayed at its original size.",
+ "name": "image_size",
+ "type": "tuple[int, int] | None"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(message: str, percentage: float | None = None, image: Image | None = None, image_size: tuple[int, int] | None = None) -> None"
+ }
+ ],
+ "name": "UtilInterface"
+ },
+ {
+ "description": "",
+ "methods": [
+ {
+ "description": "Adds an image to a board.",
+ "name": "add_image_to_board",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The ID of the board to add the image to.",
+ "name": "board_id",
+ "type": "str"
+ },
+ {
+ "default": "",
+ "description": "The name of the image to add to the board.",
+ "name": "image_name",
+ "type": "str"
+ }
+ ],
+ "return_type": "None",
+ "returns": "",
+ "signature": "(board_id: str, image_name: str) -> None"
+ },
+ {
+ "description": "Creates a board for the current user.",
+ "name": "create",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The name of the board to create.",
+ "name": "board_name",
+ "type": "str"
+ }
+ ],
+ "return_type": "BoardDTO",
+ "returns": "The created board DTO.",
+ "signature": "(board_name: str) -> BoardDTO"
+ },
+ {
+ "description": "Gets all boards accessible to the current user.",
+ "name": "get_all",
+ "parameters": [],
+ "return_type": "list[BoardDTO]",
+ "returns": "A list of all boards accessible to the current user.",
+ "signature": "() -> list[BoardDTO]"
+ },
+ {
+ "description": "Gets all image names for a board.",
+ "name": "get_all_image_names_for_board",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The ID of the board to get the image names for.",
+ "name": "board_id",
+ "type": "str"
+ }
+ ],
+ "return_type": "list[str]",
+ "returns": "A list of all image names for the board.",
+ "signature": "(board_id: str) -> list[str]"
+ },
+ {
+ "description": "Gets a board DTO.",
+ "name": "get_dto",
+ "parameters": [
+ {
+ "default": "",
+ "description": "The ID of the board to get.",
+ "name": "board_id",
+ "type": "str"
+ }
+ ],
+ "return_type": "BoardDTO",
+ "returns": "The board DTO.",
+ "signature": "(board_id: str) -> BoardDTO"
+ }
+ ],
+ "name": "BoardsInterface"
+ }
+ ],
+ "name": "InvocationContext"
+}
diff --git a/docs/src/generated/settings.json b/docs/src/generated/settings.json
new file mode 100644
index 00000000000..1987a90abce
--- /dev/null
+++ b/docs/src/generated/settings.json
@@ -0,0 +1,843 @@
+{
+ "settings": [
+ {
+ "category": "WEB",
+ "default": "127.0.0.1",
+ "description": "IP address to bind to. Use `0.0.0.0` to serve to your local network.",
+ "env_var": "INVOKEAI_HOST",
+ "literal_values": [],
+ "name": "host",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": 9090,
+ "description": "Port to bind to.",
+ "env_var": "INVOKEAI_PORT",
+ "literal_values": [],
+ "name": "port",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": [],
+ "description": "Allowed CORS origins.",
+ "env_var": "INVOKEAI_ALLOW_ORIGINS",
+ "literal_values": [],
+ "name": "allow_origins",
+ "required": false,
+ "type": "list[str]",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": true,
+ "description": "Allow CORS credentials.",
+ "env_var": "INVOKEAI_ALLOW_CREDENTIALS",
+ "literal_values": [],
+ "name": "allow_credentials",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": [
+ "*"
+ ],
+ "description": "Methods allowed for CORS.",
+ "env_var": "INVOKEAI_ALLOW_METHODS",
+ "literal_values": [],
+ "name": "allow_methods",
+ "required": false,
+ "type": "list[str]",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": [
+ "*"
+ ],
+ "description": "Headers allowed for CORS.",
+ "env_var": "INVOKEAI_ALLOW_HEADERS",
+ "literal_values": [],
+ "name": "allow_headers",
+ "required": false,
+ "type": "list[str]",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": null,
+ "description": "SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.",
+ "env_var": "INVOKEAI_SSL_CERTFILE",
+ "literal_values": [],
+ "name": "ssl_certfile",
+ "required": false,
+ "type": "typing.Optional[pathlib.Path]",
+ "validation": {}
+ },
+ {
+ "category": "WEB",
+ "default": null,
+ "description": "SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.",
+ "env_var": "INVOKEAI_SSL_KEYFILE",
+ "literal_values": [],
+ "name": "ssl_keyfile",
+ "required": false,
+ "type": "typing.Optional[pathlib.Path]",
+ "validation": {}
+ },
+ {
+ "category": "MISC FEATURES",
+ "default": false,
+ "description": "Enable logging of parsed prompt tokens.",
+ "env_var": "INVOKEAI_LOG_TOKENIZATION",
+ "literal_values": [],
+ "name": "log_tokenization",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MISC FEATURES",
+ "default": true,
+ "description": "Enable patchmatch inpaint code.",
+ "env_var": "INVOKEAI_PATCHMATCH",
+ "literal_values": [],
+ "name": "patchmatch",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "models",
+ "description": "Path to the models directory.",
+ "env_var": "INVOKEAI_MODELS_DIR",
+ "literal_values": [],
+ "name": "models_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "models/.convert_cache",
+ "description": "Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).",
+ "env_var": "INVOKEAI_CONVERT_CACHE_DIR",
+ "literal_values": [],
+ "name": "convert_cache_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "models/.download_cache",
+ "description": "Path to the directory that contains dynamically downloaded models.",
+ "env_var": "INVOKEAI_DOWNLOAD_CACHE_DIR",
+ "literal_values": [],
+ "name": "download_cache_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "configs",
+ "description": "Path to directory of legacy checkpoint config files.",
+ "env_var": "INVOKEAI_LEGACY_CONF_DIR",
+ "literal_values": [],
+ "name": "legacy_conf_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "databases",
+ "description": "Path to InvokeAI databases directory.",
+ "env_var": "INVOKEAI_DB_DIR",
+ "literal_values": [],
+ "name": "db_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "outputs",
+ "description": "Path to directory for outputs.",
+ "env_var": "INVOKEAI_OUTPUTS_DIR",
+ "literal_values": [],
+ "name": "outputs_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "flat",
+ "description": "Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.",
+ "env_var": "INVOKEAI_IMAGE_SUBFOLDER_STRATEGY",
+ "literal_values": [
+ "flat",
+ "date",
+ "type",
+ "hash"
+ ],
+ "name": "image_subfolder_strategy",
+ "required": false,
+ "type": "typing.Literal['flat', 'date', 'type', 'hash']",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "nodes",
+ "description": "Path to directory for custom nodes.",
+ "env_var": "INVOKEAI_CUSTOM_NODES_DIR",
+ "literal_values": [],
+ "name": "custom_nodes_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "style_presets",
+ "description": "Path to directory for style presets.",
+ "env_var": "INVOKEAI_STYLE_PRESETS_DIR",
+ "literal_values": [],
+ "name": "style_presets_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "PATHS",
+ "default": "workflow_thumbnails",
+ "description": "Path to directory for workflow thumbnails.",
+ "env_var": "INVOKEAI_WORKFLOW_THUMBNAILS_DIR",
+ "literal_values": [],
+ "name": "workflow_thumbnails_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": [
+ "console"
+ ],
+ "description": "Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".",
+ "env_var": "INVOKEAI_LOG_HANDLERS",
+ "literal_values": [],
+ "name": "log_handlers",
+ "required": false,
+ "type": "list[str]",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": "color",
+ "description": "Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.",
+ "env_var": "INVOKEAI_LOG_FORMAT",
+ "literal_values": [
+ "plain",
+ "color",
+ "syslog",
+ "legacy"
+ ],
+ "name": "log_format",
+ "required": false,
+ "type": "typing.Literal['plain', 'color', 'syslog', 'legacy']",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": "info",
+ "description": "Emit logging messages at this level or higher.",
+ "env_var": "INVOKEAI_LOG_LEVEL",
+ "literal_values": [
+ "debug",
+ "info",
+ "warning",
+ "error",
+ "critical"
+ ],
+ "name": "log_level",
+ "required": false,
+ "type": "typing.Literal['debug', 'info', 'warning', 'error', 'critical']",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": false,
+ "description": "Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.",
+ "env_var": "INVOKEAI_LOG_SQL",
+ "literal_values": [],
+ "name": "log_sql",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": "warning",
+ "description": "Log level for network-related messages. 'info' and 'debug' are very verbose.",
+ "env_var": "INVOKEAI_LOG_LEVEL_NETWORK",
+ "literal_values": [
+ "debug",
+ "info",
+ "warning",
+ "error",
+ "critical"
+ ],
+ "name": "log_level_network",
+ "required": false,
+ "type": "typing.Literal['debug', 'info', 'warning', 'error', 'critical']",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": false,
+ "description": "Use in-memory database. Useful for development.",
+ "env_var": "INVOKEAI_USE_MEMORY_DB",
+ "literal_values": [],
+ "name": "use_memory_db",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": false,
+ "description": "Automatically reload when Python sources are changed. Does not reload node definitions.",
+ "env_var": "INVOKEAI_DEV_RELOAD",
+ "literal_values": [],
+ "name": "dev_reload",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": false,
+ "description": "Enable graph profiling using `cProfile`.",
+ "env_var": "INVOKEAI_PROFILE_GRAPHS",
+ "literal_values": [],
+ "name": "profile_graphs",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": null,
+ "description": "An optional prefix for profile output files.",
+ "env_var": "INVOKEAI_PROFILE_PREFIX",
+ "literal_values": [],
+ "name": "profile_prefix",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "validation": {}
+ },
+ {
+ "category": "LOGGING",
+ "default": "profiles",
+ "description": "Path to profiles output directory.",
+ "env_var": "INVOKEAI_PROFILES_DIR",
+ "literal_values": [],
+ "name": "profiles_dir",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": null,
+ "description": "The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.",
+ "env_var": "INVOKEAI_MAX_CACHE_RAM_GB",
+ "literal_values": [],
+ "name": "max_cache_ram_gb",
+ "required": false,
+ "type": "typing.Optional[float]",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": null,
+ "description": "The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.",
+ "env_var": "INVOKEAI_MAX_CACHE_VRAM_GB",
+ "literal_values": [],
+ "name": "max_cache_vram_gb",
+ "required": false,
+ "type": "typing.Optional[float]",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": false,
+ "description": "If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.",
+ "env_var": "INVOKEAI_LOG_MEMORY_USAGE",
+ "literal_values": [],
+ "name": "log_memory_usage",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": 0,
+ "description": "How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.",
+ "env_var": "INVOKEAI_MODEL_CACHE_KEEP_ALIVE_MIN",
+ "literal_values": [],
+ "name": "model_cache_keep_alive_min",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": 3,
+ "description": "The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.",
+ "env_var": "INVOKEAI_DEVICE_WORKING_MEM_GB",
+ "literal_values": [],
+ "name": "device_working_mem_gb",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": true,
+ "description": "Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.",
+ "env_var": "INVOKEAI_ENABLE_PARTIAL_LOADING",
+ "literal_values": [],
+ "name": "enable_partial_loading",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": true,
+ "description": "Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.",
+ "env_var": "INVOKEAI_KEEP_RAM_COPY_OF_WEIGHTS",
+ "literal_values": [],
+ "name": "keep_ram_copy_of_weights",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": null,
+ "description": "DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.",
+ "env_var": "INVOKEAI_RAM",
+ "literal_values": [],
+ "name": "ram",
+ "required": false,
+ "type": "typing.Optional[float]",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": null,
+ "description": "DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.",
+ "env_var": "INVOKEAI_VRAM",
+ "literal_values": [],
+ "name": "vram",
+ "required": false,
+ "type": "typing.Optional[float]",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": true,
+ "description": "DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.",
+ "env_var": "INVOKEAI_LAZY_OFFLOAD",
+ "literal_values": [],
+ "name": "lazy_offload",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "CACHE",
+ "default": null,
+ "description": "Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.",
+ "env_var": "INVOKEAI_PYTORCH_CUDA_ALLOC_CONF",
+ "literal_values": [],
+ "name": "pytorch_cuda_alloc_conf",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "validation": {}
+ },
+ {
+ "category": "DEVICE",
+ "default": "auto",
+ "description": "Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities. Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)",
+ "env_var": "INVOKEAI_DEVICE",
+ "literal_values": [],
+ "name": "device",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "DEVICE",
+ "default": "auto",
+ "description": "Devices to use for parallel generation. `auto` (the default) uses every available GPU, running one generation session per GPU concurrently and distributing jobs fairly across users. Provide an explicit list (e.g. `[cuda:0, cuda:1]`) to use specific devices, or a single-device list (e.g. `[cuda:0]`) to run serially. On systems without a GPU, `auto` resolves to the single `cpu`/`mps` device. Valid values: `auto`, or a list whose entries are each `cpu`, `cuda`, `mps`, or `cuda:N` (where N is a device number)",
+ "env_var": "INVOKEAI_GENERATION_DEVICES",
+ "literal_values": [],
+ "name": "generation_devices",
+ "required": false,
+ "type": "typing.Union[typing.Literal['auto'], list[str]]",
+ "validation": {}
+ },
+ {
+ "category": "DEVICE",
+ "default": "auto",
+ "description": "Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.",
+ "env_var": "INVOKEAI_PRECISION",
+ "literal_values": [
+ "auto",
+ "float16",
+ "bfloat16",
+ "float32"
+ ],
+ "name": "precision",
+ "required": false,
+ "type": "typing.Literal['auto', 'float16', 'bfloat16', 'float32']",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": false,
+ "description": "Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.",
+ "env_var": "INVOKEAI_SEQUENTIAL_GUIDANCE",
+ "literal_values": [],
+ "name": "sequential_guidance",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": "auto",
+ "description": "Attention type.",
+ "env_var": "INVOKEAI_ATTENTION_TYPE",
+ "literal_values": [
+ "auto",
+ "normal",
+ "xformers",
+ "sliced",
+ "torch-sdp"
+ ],
+ "name": "attention_type",
+ "required": false,
+ "type": "typing.Literal['auto', 'normal', 'xformers', 'sliced', 'torch-sdp']",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": "auto",
+ "description": "Slice size, valid when attention_type==\"sliced\".",
+ "env_var": "INVOKEAI_ATTENTION_SLICE_SIZE",
+ "literal_values": [
+ "auto",
+ "balanced",
+ "max",
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8
+ ],
+ "name": "attention_slice_size",
+ "required": false,
+ "type": "typing.Literal['auto', 'balanced', 'max', 1, 2, 3, 4, 5, 6, 7, 8]",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": false,
+ "description": "Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).",
+ "env_var": "INVOKEAI_FORCE_TILED_DECODE",
+ "literal_values": [],
+ "name": "force_tiled_decode",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": 1,
+ "description": "The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.",
+ "env_var": "INVOKEAI_PIL_COMPRESS_LEVEL",
+ "literal_values": [],
+ "name": "pil_compress_level",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": 10000,
+ "description": "Maximum number of items in the session queue.",
+ "env_var": "INVOKEAI_MAX_QUEUE_SIZE",
+ "literal_values": [],
+ "name": "max_queue_size",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": false,
+ "description": "Empties session queue on startup. If true, disables `max_queue_history`.",
+ "env_var": "INVOKEAI_CLEAR_QUEUE_ON_STARTUP",
+ "literal_values": [],
+ "name": "clear_queue_on_startup",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "GENERATION",
+ "default": null,
+ "description": "Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.",
+ "env_var": "INVOKEAI_MAX_QUEUE_HISTORY",
+ "literal_values": [],
+ "name": "max_queue_history",
+ "required": false,
+ "type": "typing.Optional[int]",
+ "validation": {}
+ },
+ {
+ "category": "NODES",
+ "default": null,
+ "description": "List of nodes to allow. Omit to allow all.",
+ "env_var": "INVOKEAI_ALLOW_NODES",
+ "literal_values": [],
+ "name": "allow_nodes",
+ "required": false,
+ "type": "typing.Optional[list[str]]",
+ "validation": {}
+ },
+ {
+ "category": "NODES",
+ "default": null,
+ "description": "List of nodes to deny. Omit to deny none.",
+ "env_var": "INVOKEAI_DENY_NODES",
+ "literal_values": [],
+ "name": "deny_nodes",
+ "required": false,
+ "type": "typing.Optional[list[str]]",
+ "validation": {}
+ },
+ {
+ "category": "NODES",
+ "default": 512,
+ "description": "How many cached nodes to keep in memory.",
+ "env_var": "INVOKEAI_NODE_CACHE_SIZE",
+ "literal_values": [],
+ "name": "node_cache_size",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MODEL INSTALL",
+ "default": "blake3_single",
+ "description": "Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.",
+ "env_var": "INVOKEAI_HASHING_ALGORITHM",
+ "literal_values": [
+ "blake3_multi",
+ "blake3_single",
+ "random",
+ "md5",
+ "sha1",
+ "sha224",
+ "sha256",
+ "sha384",
+ "sha512",
+ "blake2b",
+ "blake2s",
+ "sha3_224",
+ "sha3_256",
+ "sha3_384",
+ "sha3_512",
+ "shake_128",
+ "shake_256"
+ ],
+ "name": "hashing_algorithm",
+ "required": false,
+ "type": "typing.Literal['blake3_multi', 'blake3_single', 'random', 'md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512', 'blake2b', 'blake2s', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'shake_128', 'shake_256']",
+ "validation": {}
+ },
+ {
+ "category": "MODEL INSTALL",
+ "default": null,
+ "description": "List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.",
+ "env_var": "INVOKEAI_REMOTE_API_TOKENS",
+ "literal_values": [],
+ "name": "remote_api_tokens",
+ "required": false,
+ "type": "typing.Optional[list[invokeai.app.services.config.config_default.URLRegexTokenPair]]",
+ "validation": {}
+ },
+ {
+ "category": "MODEL INSTALL",
+ "default": false,
+ "description": "Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.",
+ "env_var": "INVOKEAI_SCAN_MODELS_ON_STARTUP",
+ "literal_values": [],
+ "name": "scan_models_on_startup",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MODEL INSTALL",
+ "default": false,
+ "description": "UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.",
+ "env_var": "INVOKEAI_UNSAFE_DISABLE_PICKLESCAN",
+ "literal_values": [],
+ "name": "unsafe_disable_picklescan",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MODEL INSTALL",
+ "default": true,
+ "description": "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.",
+ "env_var": "INVOKEAI_ALLOW_UNKNOWN_MODELS",
+ "literal_values": [],
+ "name": "allow_unknown_models",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MULTIUSER",
+ "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.",
+ "env_var": "INVOKEAI_MULTIUSER",
+ "literal_values": [],
+ "name": "multiuser",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "MULTIUSER",
+ "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.",
+ "env_var": "INVOKEAI_STRICT_PASSWORD_CHECKING",
+ "literal_values": [],
+ "name": "strict_password_checking",
+ "required": false,
+ "type": "",
+ "validation": {}
+ },
+ {
+ "category": "EXTERNAL PROVIDERS",
+ "default": null,
+ "description": "API key for Alibaba Cloud DashScope image generation.",
+ "env_var": "INVOKEAI_EXTERNAL_ALIBABACLOUD_API_KEY",
+ "literal_values": [],
+ "name": "external_alibabacloud_api_key",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "validation": {}
+ },
+ {
+ "category": "EXTERNAL PROVIDERS",
+ "default": null,
+ "description": "Base URL override for Alibaba Cloud DashScope image generation.",
+ "env_var": "INVOKEAI_EXTERNAL_ALIBABACLOUD_BASE_URL",
+ "literal_values": [],
+ "name": "external_alibabacloud_base_url",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "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": {}
+ },
+ {
+ "category": "EXTERNAL PROVIDERS",
+ "default": null,
+ "description": "API key for Seedream image generation.",
+ "env_var": "INVOKEAI_EXTERNAL_SEEDREAM_API_KEY",
+ "literal_values": [],
+ "name": "external_seedream_api_key",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "validation": {}
+ },
+ {
+ "category": "EXTERNAL PROVIDERS",
+ "default": null,
+ "description": "Base URL override for Seedream image generation.",
+ "env_var": "INVOKEAI_EXTERNAL_SEEDREAM_BASE_URL",
+ "literal_values": [],
+ "name": "external_seedream_base_url",
+ "required": false,
+ "type": "typing.Optional[str]",
+ "validation": {}
+ }
+ ]
+}
diff --git a/docs/src/layouts/PageFrameExtended.astro b/docs/src/layouts/PageFrameExtended.astro
new file mode 100644
index 00000000000..9287376a4b9
--- /dev/null
+++ b/docs/src/layouts/PageFrameExtended.astro
@@ -0,0 +1,9 @@
+---
+import PageFrame from '@astrojs/starlight/components/PageFrame.astro';
+---
+
+
+
+
+
+
diff --git a/docs/src/lib/base-path.ts b/docs/src/lib/base-path.ts
new file mode 100644
index 00000000000..23042d899a5
--- /dev/null
+++ b/docs/src/lib/base-path.ts
@@ -0,0 +1,6 @@
+export const withBase = (path: string, baseUrl: string) => {
+ const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ const normalizedPath = path.replace(/^\//, '');
+
+ return `${normalizedBase}${normalizedPath}`;
+};
diff --git a/docs/src/lib/components/DownloadOptions.astro b/docs/src/lib/components/DownloadOptions.astro
new file mode 100644
index 00000000000..dfae32a4915
--- /dev/null
+++ b/docs/src/lib/components/DownloadOptions.astro
@@ -0,0 +1,197 @@
+---
+import { LinkCard, Icon, LinkButton } from '@astrojs/starlight/components';
+import { type StarlightIcon } from '@astrojs/starlight/types';
+import { withBase } from '../base-path';
+
+type LauncherDownloadOption = {
+ icon: StarlightIcon;
+ headline: string;
+ note: string;
+ launcherDownloadLink: string;
+ launcherDownloadLabel?: string;
+};
+const launcherDownloadOptions: Record = {
+ windows: {
+ icon: 'seti:windows',
+ headline: 'Download for Windows',
+ note: 'Requires Windows 10 or later, and NVIDIA or AMD GPU.',
+ launcherDownloadLink:
+ 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition.Setup.latest.exe',
+ launcherDownloadLabel: 'Download EXE',
+ },
+ macos: {
+ icon: 'apple',
+ headline: 'Download for MacOS',
+ note: 'Requires Apple Silicon (M-Series). Not compatible with Intel.',
+ launcherDownloadLink:
+ 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest-arm64.dmg',
+ launcherDownloadLabel: 'Download DMG',
+ },
+ linux: {
+ icon: 'linux',
+ headline: 'Download for Linux',
+ note: 'Requires NVIDIA or AMD GPU. Compatible with most distributions.',
+ launcherDownloadLink:
+ 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest.AppImage',
+ launcherDownloadLabel: 'Download AppImage',
+ },
+};
+
+const manualDownloadOptions = {
+ github: {
+ headline: 'Download from GitHub',
+ description: 'For advanced users who want to set up Invoke manually or contribute to the project.',
+ href: 'https://github.com/invoke-ai/InvokeAI/releases',
+ },
+ docker: {
+ headline: 'Run with Docker',
+ description: 'For users who want to run Invoke without installing dependencies directly on their system.',
+ href: withBase('/configuration/docker/', import.meta.env.BASE_URL),
+ },
+};
+---
+
+
+
+
+ {
+ Object.entries(launcherDownloadOptions).map(
+ ([key, { icon, headline, note, launcherDownloadLink, launcherDownloadLabel }]) => (
+
+
+
{headline}
+
{note}
+
+
+
+ {launcherDownloadLabel}
+
+
+
+ ),
+ )
+ }
+
+
+
+
+ OR
+
+
+
+
+ {
+ Object.entries(manualDownloadOptions).map(([key, { headline, href, description }]) => (
+
+ ))
+ }
+
+
+
+
+
+
diff --git a/docs/src/lib/components/EmptyComponent.astro b/docs/src/lib/components/EmptyComponent.astro
new file mode 100644
index 00000000000..a04846e64d7
--- /dev/null
+++ b/docs/src/lib/components/EmptyComponent.astro
@@ -0,0 +1,3 @@
+---
+// This is used to override starlight components we don't want to use
+---
diff --git a/docs/src/lib/components/Footer.astro b/docs/src/lib/components/Footer.astro
new file mode 100644
index 00000000000..65137dec3b9
--- /dev/null
+++ b/docs/src/lib/components/Footer.astro
@@ -0,0 +1,28 @@
+---
+import PageFooter from '@astrojs/starlight/components/Footer.astro';
+---
+
+
+
+
+
+
diff --git a/docs/src/lib/components/ForceDarkTheme.astro b/docs/src/lib/components/ForceDarkTheme.astro
new file mode 100644
index 00000000000..f9c102a1ce8
--- /dev/null
+++ b/docs/src/lib/components/ForceDarkTheme.astro
@@ -0,0 +1,12 @@
+---
+
+---
+
+
+
diff --git a/docs/src/lib/components/InvocationContextDocs.astro b/docs/src/lib/components/InvocationContextDocs.astro
new file mode 100644
index 00000000000..851ac8a25b1
--- /dev/null
+++ b/docs/src/lib/components/InvocationContextDocs.astro
@@ -0,0 +1,324 @@
+---
+import invocationContext from '../../generated/invocation-context.json';
+
+/** Strip "Interface" suffix for the access path hint, e.g. "ImagesInterface" -> "context.images" */
+const accessPath = (name: string) => {
+ const stripped = name.replace(/Interface$/, '').toLowerCase();
+ return `context.${stripped}`;
+};
+
+/** Build a URL-friendly anchor id from an interface name, e.g. "ImagesInterface" -> "imagesinterface" */
+const ifaceId = (name: string) => name.toLowerCase();
+
+/** Build a URL-friendly anchor id for a method, e.g. ("ImagesInterface","get_dto") -> "imagesinterface--get_dto" */
+const methodId = (ifaceName: string, methodName: string) =>
+ `${ifaceName.toLowerCase()}--${methodName}`;
+
+/** Lightweight markdown-to-HTML for docstring content.
+ * Handles: fenced code blocks (```lang ... ```), inline `code`, **bold**, and paragraphs. */
+const miniMarkdown = (text: string): string => {
+ // HTML-escape first
+ let s = text.replace(/&/g, '&').replace(//g, '>');
+ // Fenced code blocks: ```lang\n...\n```
+ s = s.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => {
+ return `${code.replace(/^\n|\n$/g, '')} `;
+ });
+ // Inline code: `...`
+ s = s.replace(/`([^`]+)`/g, '$1');
+ // Bold: **...**
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
+ // Convert double newlines to paragraph breaks
+ s = s.replace(/\n\n+/g, '');
+ return `
${s}
`;
+};
+
+/** Inline-only markdown: just backtick `code` and **bold**, no block elements. */
+const inlineMarkdown = (text: string): string => {
+ let s = text.replace(/&/g, '&').replace(//g, '>');
+ s = s.replace(/`([^`]+)`/g, '$1');
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
+ return s;
+};
+
+/** Get the first sentence/line of a description for the summary table. */
+const summaryDesc = (desc: string) => {
+ if (!desc) return '';
+ // Take up to first newline or period+space
+ const nl = desc.indexOf('\n');
+ const trimmed = nl !== -1 ? desc.substring(0, nl) : desc;
+ return trimmed;
+};
+---
+
+{invocationContext.interfaces.map((iface) => (
+
+
+ {iface.name}
+ {accessPath(iface.name)}
+
+
+ {iface.description &&
}
+
+ {/* ── Summary table ── */}
+
+
+
+ Method
+ Description
+
+
+
+ {iface.methods.map((method) => (
+
+ {method.name}
+
+
+ ))}
+
+
+
+ {/* ── Per-method details ── */}
+ {iface.methods.map((method) => (
+
+
{method.name}
+
+
{method.signature}
+
+ {method.description && (
+
+ )}
+
+ {method.parameters.length > 0 && (
+ <>
+
Parameters
+
+
+
+ Name
+ Type
+ Description
+ Default
+
+
+
+ {method.parameters.map((param) => (
+
+ {param.name}
+ {param.type || '—'}
+
+ {param.default ? {param.default} : required }
+
+ ))}
+
+
+ >
+ )}
+
+ {(method.returns || method.return_type) && (
+ <>
+
Returns
+
+
+
+ Type
+ Description
+
+
+
+
+ {method.return_type || '—'}
+
+
+
+
+ >
+ )}
+
+ ))}
+
+))}
+
+
diff --git a/docs/src/lib/components/Link.astro b/docs/src/lib/components/Link.astro
new file mode 100644
index 00000000000..cca88478340
--- /dev/null
+++ b/docs/src/lib/components/Link.astro
@@ -0,0 +1,23 @@
+---
+type Props = {
+ href: string;
+ label?: string;
+ [key: string]: any;
+};
+
+const { href, label, ...rest } = Astro.props as Props;
+
+const useSlot = !!Astro.slots.has('default');
+const isExternal = /^https?:\/\//.test(href);
+---
+
+
+ {useSlot ? : label}
+
diff --git a/docs/src/lib/components/Mermaid.astro b/docs/src/lib/components/Mermaid.astro
new file mode 100644
index 00000000000..18a59eba203
--- /dev/null
+++ b/docs/src/lib/components/Mermaid.astro
@@ -0,0 +1,58 @@
+---
+type Props = {
+ title?: string;
+};
+
+const { title = '' } = Astro.props as Props;
+---
+
+
+
+
+ {title}
+
+ Loading diagram...
+
+
+ Source
+
+
+
diff --git a/docs/src/lib/components/SettingsDocs.astro b/docs/src/lib/components/SettingsDocs.astro
new file mode 100644
index 00000000000..c539b27cd46
--- /dev/null
+++ b/docs/src/lib/components/SettingsDocs.astro
@@ -0,0 +1,231 @@
+---
+import settingsData from '../../generated/settings.json';
+
+const groupedSettings = Object.entries(
+ settingsData.settings.reduce((groups, setting) => {
+ const category = setting.category || 'OTHER';
+ if (!groups[category]) {
+ groups[category] = [];
+ }
+ groups[category].push(setting);
+ return groups;
+ }, {}),
+);
+
+const formatValue = (value) => {
+ if (value === null) {
+ return 'null';
+ }
+ if (Array.isArray(value) || typeof value === 'object') {
+ return JSON.stringify(value);
+ }
+ return String(value);
+};
+
+/** Clean up Python type representations for display */
+const formatType = (typeStr) => {
+ // Strip wrapper
+ const classMatch = typeStr.match(/^$/);
+ if (classMatch) {
+ const inner = classMatch[1];
+ // Strip module paths (e.g. pathlib.Path -> Path)
+ return inner.split('.').pop();
+ }
+ // Strip typing. prefix
+ let cleaned = typeStr.replace(/typing\./g, '');
+ // Strip module paths inside brackets
+ cleaned = cleaned.replace(/[a-z_][a-z0-9_.]*\.([A-Z][A-Za-z]*)/g, '$1');
+ return cleaned;
+};
+
+/** Format category name: "MODEL INSTALL" -> "Model Install" */
+const formatCategoryName = (category) => {
+ return category
+ .split(' ')
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ');
+};
+---
+
+{groupedSettings.map(([category, settings]) => (
+
+
+ {formatCategoryName(category)}
+ {settings.length}
+
+
+ {settings.map((setting) => (
+
+
+
+ {setting.name}
+
+
+
+ Type
+ {formatType(setting.type)}
+
+
+ Default
+ {formatValue(setting.default)}
+
+
+ Env
+ {setting.env_var}
+
+
+
+ {setting.description}
+ {setting.literal_values.length > 0 && (
+
+ Values: {setting.literal_values.map((v) => {formatValue(v)}).reduce((prev, curr) => [prev, ' ', curr])}
+
+ )}
+ {Object.keys(setting.validation).length > 0 && (
+
+ {Object.entries(setting.validation).map(([key, value]) => (
+ {key}={formatValue(value)}
+ )).reduce((prev, curr) => [prev, ' ', curr])}
+
+ )}
+
+
+ ))}
+
+
+))}
+
+
diff --git a/docs/src/lib/components/SplashGallery.astro b/docs/src/lib/components/SplashGallery.astro
new file mode 100644
index 00000000000..a0293e48ff6
--- /dev/null
+++ b/docs/src/lib/components/SplashGallery.astro
@@ -0,0 +1,263 @@
+---
+// Community Gallery Marquee
+
+import {Image} from 'astro:assets';
+
+import linearview from '../../content/docs/workflows/assets/linearview.png';
+import workflowLibrary from '../../content/docs/workflows/assets/workflow_library.png';
+import groupsMultigenSeeding from '../../content/docs/workflows/assets/groupsmultigenseeding.png';
+import groupsImgVae from '../../content/docs/workflows/assets/groupsimgvae.png';
+import groupsConditioning from '../../content/docs/workflows/assets/groupsconditioning.png';
+import groupsControl from '../../content/docs/workflows/assets/groupscontrol.png';
+import groupsLora from '../../content/docs/workflows/assets/groupslora.png';
+import groupsNoise from '../../content/docs/workflows/assets/groupsnoise.png';
+import groupsIterate from '../../content/docs/workflows/assets/groupsiterate.png';
+
+const placeholderSocials = [
+ { label: 'Instagram', href: 'https://example.com/instagram' },
+ { label: 'X', href: 'https://example.com/x' },
+ { label: 'ArtStation', href: 'https://example.com/artstation' },
+];
+
+const rows = [
+ [
+ {
+ id: 'linearview',
+ image: linearview,
+ alt: 'Linear workflow editor view',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'workflow-library',
+ image: workflowLibrary,
+ alt: 'Workflow library browser view',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'conditioning',
+ image: groupsConditioning,
+ alt: 'Workflow conditioning group',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'img-vae',
+ image: groupsImgVae,
+ alt: 'Workflow image and VAE group',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'multigen',
+ image: groupsMultigenSeeding,
+ alt: 'Workflow multigen seeding group',
+ socials: placeholderSocials,
+ },
+ ],
+ [
+ {
+ id: 'control',
+ image: groupsControl,
+ alt: 'Workflow control group',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'lora',
+ image: groupsLora,
+ alt: 'Workflow LoRA group',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'noise',
+ image: groupsNoise,
+ alt: 'Workflow noise group',
+ socials: placeholderSocials,
+ },
+ {
+ id: 'iterate',
+ image: groupsIterate,
+ alt: 'Workflow iterate group',
+ socials: placeholderSocials,
+ },
+ ],
+];
+---
+
+
+ {rows.map((row, rowIndex) => (
+
+
+
+ {[...Array(2)].map((_, segmentIndex) => (
+
+ {row.map((tile) => (
+
+
+
+ Submitted by
+
+
+
+ ))}
+
+ ))}
+
+
+ ))}
+
+
+
diff --git a/docs/src/lib/components/SystemRequirmentsLink.astro b/docs/src/lib/components/SystemRequirmentsLink.astro
new file mode 100644
index 00000000000..16455b8e749
--- /dev/null
+++ b/docs/src/lib/components/SystemRequirmentsLink.astro
@@ -0,0 +1,10 @@
+---
+import { LinkCard } from '@astrojs/starlight/components';
+import { withBase } from '../base-path';
+---
+
+
diff --git a/docs/src/pages/download.astro b/docs/src/pages/download.astro
new file mode 100644
index 00000000000..1160566dadc
--- /dev/null
+++ b/docs/src/pages/download.astro
@@ -0,0 +1,17 @@
+---
+import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
+import DownloadOptions from '@components/DownloadOptions.astro';
+---
+
+
+
+
diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css
new file mode 100644
index 00000000000..abb93263edd
--- /dev/null
+++ b/docs/src/styles/custom.css
@@ -0,0 +1,397 @@
+:root {
+ /* Page Layout */
+ --sl-content-width: 84ch;
+
+ /* Typography */
+ --__sl-font: 'Inter', sans-serif;
+ --__sl-font-mono:
+ 'Roboto Mono', SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
+
+ --radius: 0.35rem;
+
+ /* Colors */
+ --sl-color-bg: #1c1f23;
+ --sl-color-bg-nav: #31343b;
+ --sl-color-bg-sidebar: #272a2f;
+
+ --sl-color-gray-7: #272a2f;
+
+ --sl-color-hairline: rgba(255, 255, 255, 0.08);
+ --sl-color-hairline-light: rgba(255, 255, 255, 0.16);
+
+ --sl-color-text-accent: #97d2ee;
+ --sl-color-text-accent-2: #e4fd1d;
+
+ --sl-color-accent-2-rgb: 228, 253, 29;
+}
+
+html,
+body {
+ scroll-behavior: smooth;
+}
+
+.text-xs {
+ font-size: var(--sl-text-xs);
+}
+
+[data-has-hero] {
+ header {
+ background-color: var(--sl-color-bg);
+ border-color: transparent;
+
+ .header {
+ max-width: calc(var(--sl-content-width) + 8rem);
+ margin-inline: auto;
+ }
+ }
+}
+
+.site-title {
+ transition: transform 100ms ease-in-out;
+
+ &:hover {
+ transform: scale(1.02);
+ }
+ &:active {
+ transform: scale(0.98);
+ }
+}
+
+.hero {
+ padding-top: clamp(2.5rem, calc(1rem + 10vmin), 5rem);
+ padding-bottom: clamp(2.5rem, calc(1rem + 10vmin), 10rem);
+
+ &:has(> :only-child) {
+ grid-template-columns: 1fr;
+ gap: 0;
+
+ .sl-flex {
+ align-items: center;
+ text-align: center;
+ }
+ }
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ .title--wrapper {
+ flex-shrink: 0;
+ }
+
+ /* Site Search Container */
+ .sl-flex:has(site-search) {
+ order: 2;
+ justify-content: end;
+ width: 100%;
+ max-width: 22rem;
+
+ @media (max-width: 799px) {
+ width: auto;
+ }
+ }
+
+ /* Social Items */
+ > :last-child:has(> .social-icons) {
+ order: 1;
+ margin-left: auto;
+ justify-content: end;
+ }
+}
+
+.page {
+ background-image: radial-gradient(circle, var(--sl-color-hairline) 1px, transparent 1px);
+ background-size: 20px 20px;
+}
+
+.right-sidebar-container {
+ background: var(--sl-color-bg);
+}
+
+#starlight__sidebar .sidebar-content {
+ a {
+ transition:
+ background 50ms ease-in-out,
+ color 50ms ease-in-out;
+
+ &:not([aria-current='page']):hover {
+ background: var(--sl-color-hairline);
+ }
+ &:not([aria-current='page']):active {
+ background: var(--sl-color-hairline-light);
+ }
+
+ &[aria-current='page'] span,
+ span {
+ font-weight: normal;
+ }
+ }
+}
+
+site-search > button {
+ transition: border-color 100ms ease-in-out;
+}
+
+.sl-link-button {
+ border-radius: 0.5rem;
+}
+
+.sl-link-card {
+ background: var(--sl-color-bg);
+ transition: border-color 100ms ease-in-out;
+
+ &:active {
+ border-color: var(--sl-color-hairline-light);
+ }
+
+ svg {
+ transition: color 100ms ease-in-out;
+ }
+}
+
+.expressive-code .frame pre {
+ background: var(--sl-color-bg-sidebar);
+}
+
+.expressive-code .has-title {
+ .header {
+ border-bottom: var(--ec-brdWd) solid var(--ec-brdCol);
+ }
+ .header .title {
+ border-inline: var(--ec-brdWd) solid var(--ec-brdCol);
+ border-top: var(--ec-brdWd) solid var(--ec-brdCol);
+ background: var(--sl-color-bg-sidebar);
+ font-family: var(--__sl-font-mono);
+ font-size: var(--sl-text-xs);
+ padding: calc(var(--ec-uiPadBlk) + var(--ec-frm-edActTabIndHt)) var(--ec-uiPadInl);
+ cursor: pointer;
+
+ &::after {
+ display: none;
+ }
+
+ &::before {
+ position: absolute;
+ content: '';
+ inset: 0;
+ background: var(--sl-color-hairline);
+ opacity: 0;
+ transition: opacity 75ms ease-in-out;
+ }
+
+ &:hover::before {
+ opacity: 1;
+ }
+ }
+}
+
+ul[role='tablist'] {
+ border-bottom: 1px solid var(--sl-color-hairline);
+}
+
+a[role='tab'] {
+ border: none;
+ padding: 0.275rem 0.5rem;
+ transition: all 100ms ease;
+ border-radius: var(--radius);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom-width: 2px;
+ border-bottom-style: solid;
+ box-shadow: none;
+
+ &:not([aria-selected='true']) {
+ border-color: transparent;
+ color: var(--sl-color-text);
+ }
+
+ &:not([aria-selected='true']):hover {
+ background: var(--sl-color-hairline);
+ }
+
+ &:not([aria-selected='true']):active {
+ background: var(--sl-color-hairline-light);
+ }
+
+ &[aria-selected='true'] {
+ font-weight: normal;
+ --sl-tab-color-border: var(--sl-color-text-accent);
+ color: var(--sl-color-text-accent);
+ }
+}
+
+/* Decorate tabs with parent aside colors */
+aside a[role='tab'] {
+ &[aria-selected='true'] {
+ --sl-tab-color-border: var(--sl-color-asides-border);
+ color: var(--sl-color-asides-text-accent);
+ }
+}
+
+a[rel='next'],
+a[rel='prev'] {
+ background: var(--sl-color-bg);
+ transition: border-color 100ms ease-in-out;
+}
+
+.sl-steps {
+ & > li::before {
+ font-family: var(--__sl-font-mono);
+ }
+}
+
+article.card {
+ border-radius: var(--radius);
+
+ padding: clamp(1rem, calc(0.125rem + 3vw), 1.5rem);
+}
+
+.starlight-aside {
+ border-radius: var(--radius);
+ border: none;
+ position: relative;
+ padding: 0.75rem;
+ padding-left: 1.5rem;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0.35rem;
+ width: 0.25rem;
+ inset-block: 0.35rem;
+ border-radius: 999px;
+ background: var(--sl-color-asides-border);
+ }
+}
+
+.hero .actions {
+ gap: 1.5rem;
+}
+
+.card .sl-link-button {
+ margin-bottom: 0;
+}
+
+.sl-link-button {
+ position: relative;
+ overflow: hidden;
+ transition: transform 100ms ease-in-out;
+
+ &:not(.minimal) {
+ padding: 0.65rem 0.85rem;
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--sl-color-hairline);
+ opacity: 0;
+ transition: opacity 75ms ease-in-out;
+ pointer-events: none;
+ }
+ }
+
+ &:hover::before {
+ opacity: 1;
+ }
+
+ &:hover,
+ &:focus-visible {
+ transform: scale(1.02);
+ }
+
+ &:active {
+ transform: scale(0.98);
+ }
+
+ &.primary::before {
+ background: rgba(0, 0, 0, 0.12);
+ }
+
+ &.secondary {
+ border-color: var(--sl-color-gray-5);
+ }
+}
+
+/* Contextual Menu */
+#contextual-menu-container {
+
+ & > button {
+ transition: background 100ms ease-in-out;
+ }
+
+ #contextual-dropdown-menu button {
+ transition: background 75ms ease-in-out;
+ }
+}
+
+/* TODO: Custom markdown content styles */
+.sl-markdown-content {
+ table {
+ :is(th:first-child, td:first-child):not(:where(.not-content *)) {
+ padding: 0.5rem 1rem;
+ }
+ tr:hover {
+ background-color: var(--sl-color-bg-sidebar);
+ }
+ }
+}
+
+/* Splash Page-specific styles */
+
+@keyframes splash-animate {
+ 0% { background-position: 0% 0%; }
+ 50% { background-position: 0% 100%; }
+ 100% { background-position: 0% 0%; }
+}
+
+.splash-img {
+ --thickness: 2px;
+
+ position: relative;
+
+ img {
+ position: relative;
+ z-index: 2;
+ border-radius: var(--radius);
+ }
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ display: block;
+ background-size: 100% 200%;
+ animation: splash-animate 6s ease-in-out infinite;
+ }
+
+ /* Border */
+ &::before {
+ z-index: 1;
+ inset: calc(-1 * var(--thickness));
+ border-radius: calc(var(--radius) + var(--thickness));
+ background-image: linear-gradient(
+ rgb(var(--sl-color-accent-2-rgb)),
+ rgb(var(--sl-color-accent-2-rgb), 0) 80%
+ );
+ }
+
+ /* Glow */
+ &::after {
+ z-index: 0;
+ /* Mirror the border's gradient */
+ background-image: linear-gradient(
+ rgb(var(--sl-color-accent-2-rgb)),
+ rgba(var(--sl-color-accent-2-rgb), 0) 70%
+ );
+ /* Diffuse the gradient into a glow */
+ filter: blur(24px);
+ inset: -6px;
+ opacity: 0.7;
+ border-radius: calc(var(--radius) * 3);
+ opacity: 0.65;
+ }
+}
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
new file mode 100644
index 00000000000..555a061bdcb
--- /dev/null
+++ b/docs/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"],
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./*"],
+ "@lib/*": ["./src/lib/*"],
+ "@utils/*": ["./src/lib/utils/*"],
+ "@components/*": ["./src/lib/components/*"],
+ },
+ },
+}
diff --git a/docs/workflows/ESRGAN_img2img_upscale_w_Canny_ControlNet.json b/docs/workflows/ESRGAN_img2img_upscale_w_Canny_ControlNet.json
deleted file mode 100644
index abde0cab76b..00000000000
--- a/docs/workflows/ESRGAN_img2img_upscale_w_Canny_ControlNet.json
+++ /dev/null
@@ -1,1364 +0,0 @@
-{
- "id": "6bfa0b3a-7090-4cd9-ad2d-a4b8662b6e71",
- "name": "ESRGAN Upscaling with Canny ControlNet",
- "author": "InvokeAI",
- "description": "Sample workflow for using Upscaling with ControlNet with SD1.5",
- "version": "1.0.1",
- "contact": "invoke@invoke.ai",
- "tags": "upscale, controlnet, default",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "fieldName": "model"
- },
- {
- "nodeId": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "fieldName": "prompt"
- },
- {
- "nodeId": "771bdf6a-0813-4099-a5d8-921a138754d4",
- "fieldName": "image"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
- "type": "invocation",
- "data": {
- "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5f762fae-d791-42d9-8ab5-2b830c33ff20",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "8ac95f40-317d-4513-bbba-b99effd3b438",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "46c65b2b-c0b5-40c2-b183-74e9451c6d56",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 1250,
- "y": 1500
- }
- },
- {
- "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "type": "invocation",
- "data": {
- "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "type": "main_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "b35ae88a-f2d2-43f6-958c-8c624391250f",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MainModelField"
- },
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "02f243cb-c6e2-42c5-8be9-ef0519d54383",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "7762ed13-5b28-40f4-85f1-710942ceb92a",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "69566153-1918-417d-a3bb-32e9e857ef6b",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 227,
- "position": {
- "x": 700,
- "y": 1375
- }
- },
- {
- "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
- "type": "invocation",
- "data": {
- "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
- "type": "image",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "0f6d68a2-38bd-4f65-a112-0a256c7a2678",
- "name": "image",
- "fieldKind": "input",
- "label": "Image To Upscale",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- }
- },
- "outputs": {
- "image": {
- "id": "76f6f9b6-755b-4373-93fa-6a779998d2c8",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "6858e46b-707c-444f-beda-9b5f4aecfdf8",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "421bdc6e-ecd1-4935-9665-d38ab8314f79",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 375,
- "y": 1900
- }
- },
- {
- "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "type": "invocation",
- "data": {
- "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "type": "esrgan",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.3.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "8fa0c7eb-5bd3-4575-98e7-72285c532504",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "3c949799-a504-41c9-b342-cff4b8146c48",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "model_name": {
- "id": "77cb4750-53d6-4c2c-bb5c-145981acbf17",
- "name": "model_name",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "RealESRGAN_x4plus.pth"
- },
- "tile_size": {
- "id": "7787b3ad-46ee-4248-995f-bc740e1f988b",
- "name": "tile_size",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 400
- }
- },
- "outputs": {
- "image": {
- "id": "37e6308e-e926-4e07-b0db-4e8601f495d0",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "c194d84a-fac7-4856-b646-d08477a5ad2b",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "b2a6206c-a9c8-4271-a055-0b93a7f7d505",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 775,
- "y": 1900
- }
- },
- {
- "id": "1d887701-df21-4966-ae6e-a7d82307d7bd",
- "type": "invocation",
- "data": {
- "id": "1d887701-df21-4966-ae6e-a7d82307d7bd",
- "type": "canny_image_processor",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "52c877c8-25d9-4949-8518-f536fcdd152d",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "e0af11fe-4f95-4193-a599-cf40b6a963f5",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "low_threshold": {
- "id": "ab775f7b-f556-4298-a9d6-2274f3a6c77c",
- "name": "low_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 100
- },
- "high_threshold": {
- "id": "9e58b615-06e4-417f-b0d8-63f1574cd174",
- "name": "high_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 200
- }
- },
- "outputs": {
- "image": {
- "id": "61feb8bf-95c9-4634-87e2-887fc43edbdf",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "9e203e41-73f7-4cfa-bdca-5040e5e60c55",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "ec7d99dc-0d82-4495-a759-6423808bff1c",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 1200,
- "y": 1900
- }
- },
- {
- "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
- "type": "invocation",
- "data": {
- "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
- "type": "controlnet",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "2973c126-e301-4595-a7dc-d6e1729ccdbf",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "control_model": {
- "id": "4bb4d987-8491-4839-b41b-6e2f546fe2d0",
- "name": "control_model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlNetModelField"
- },
- "value": {
- "model_name": "canny",
- "base_model": "sd-1"
- }
- },
- "control_weight": {
- "id": "a3cf387a-b58f-4058-858f-6a918efac609",
- "name": "control_weight",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 1
- },
- "begin_step_percent": {
- "id": "e0614f69-8a58-408b-9238-d3a44a4db4e0",
- "name": "begin_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "end_step_percent": {
- "id": "ac683539-b6ed-4166-9294-2040e3ede206",
- "name": "end_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "control_mode": {
- "id": "f00b21de-cbd7-4901-8efc-e7134a2dc4c8",
- "name": "control_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "balanced"
- },
- "resize_mode": {
- "id": "cafb60ee-3959-4d57-a06c-13b83be6ea4f",
- "name": "resize_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "just_resize"
- }
- },
- "outputs": {
- "control": {
- "id": "dfb88dd1-12bf-4034-9268-e726f894c131",
- "name": "control",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlField"
- }
- }
- }
- },
- "width": 320,
- "height": 511,
- "position": {
- "x": 1650,
- "y": 1900
- }
- },
- {
- "id": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "invocation",
- "data": {
- "id": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "noise",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "f76b0e01-b601-423f-9b5f-ab7a1f10fe82",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "eec326d6-710c-45de-a25c-95704c80d7e2",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "2794a27d-5337-43ca-95d9-41b673642c94",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "use_cpu": {
- "id": "ae7654e3-979e-44a1-8968-7e3199e91e66",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "8b6dc166-4ead-4124-8ac9-529814b0cbb9",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "e3fe3940-a277-4838-a448-5f81f2a7d99d",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "48ecd6ef-c216-40d5-9d1b-d37bd00c82e7",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 1650,
- "y": 1775
- }
- },
- {
- "id": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "invocation",
- "data": {
- "id": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "e127084b-72f5-4fe4-892b-84f34f88bce9",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "72cde4ee-55de-4d3e-9057-74e741c04e20",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "747f7023-1c19-465b-bec8-1d9695dd3505",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "80860292-633c-46f2-83d0-60d0029b65d2",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 10
- },
- "cfg_scale": {
- "id": "ebc71e6f-9148-4f12-b455-5e1f179d1c3a",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "ced44b8f-3bad-4c34-8113-13bc0faed28a",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "79bf4b77-3502-4f72-ba8b-269c4c3c5c72",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "ed56e2b8-f477-41a2-b9f5-f15f4933ae65",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- },
- "unet": {
- "id": "146b790c-b08e-437c-a2e1-e393c2c1c41a",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "75ed3df1-d261-4b8e-a89b-341c4d7161fb",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "eab9a61d-9b64-44d3-8d90-4686f5887cb0",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "2dc8d637-58fd-4069-ad33-85c32d958b7b",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "391e5010-e402-4380-bb46-e7edaede3512",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "6767e40a-97c6-4487-b3c9-cad1c150bf9f",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "6251efda-d97d-4ff1-94b5-8cc6b458c184",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "4e7986a4-dff2-4448-b16b-1af477b81f8b",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "dad525dd-d2f8-4f07-8c8d-51f2a3c5456e",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "af03a089-4739-40c6-8b48-25d458d63c2f",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 705,
- "position": {
- "x": 2128.740065979906,
- "y": 1232.6219060454753
- }
- },
- {
- "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "type": "invocation",
- "data": {
- "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "9f7a1a9f-7861-4f09-874b-831af89b7474",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "a5b42432-8ee7-48cd-b61c-b97be6e490a2",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "890de106-e6c3-4c2c-8d67-b368def64894",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "b8e5a2ca-5fbc-49bd-ad4c-ea0e109d46e3",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "fdaf6264-4593-4bd2-ac71-8a0acff261af",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "94c5877d-6c78-4662-a836-8a84fc75d0a0",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "2a854e42-1616-42f5-b9ef-7b73c40afc1d",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "dd649053-1433-4f31-90b3-8bb103efc5b1",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 2559.4751127537957,
- "y": 1246.6000376741406
- }
- },
- {
- "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "type": "invocation",
- "data": {
- "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "type": "i2l",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "9e6c4010-0f79-4587-9062-29d9a8f96b3b",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "vae": {
- "id": "b9ed2ec4-e8e3-4d69-8a42-27f2d983bcd6",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "bb48d10b-2440-4c46-b835-646ae5ebc013",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "1048612c-c0f4-4abf-a684-0045e7d158f8",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "latents": {
- "id": "55301367-0578-4dee-8060-031ae13c7bf8",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "2eb65690-1f20-4070-afbd-1e771b9f8ca9",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "d5bf64c7-c30f-43b8-9bc2-95e7718c1bdc",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 1650,
- "y": 1675
- }
- },
- {
- "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "type": "invocation",
- "data": {
- "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5f762fae-d791-42d9-8ab5-2b830c33ff20",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "8ac95f40-317d-4513-bbba-b99effd3b438",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "46c65b2b-c0b5-40c2-b183-74e9451c6d56",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 1250,
- "y": 1200
- }
- },
- {
- "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35",
- "type": "invocation",
- "data": {
- "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35",
- "type": "rand_int",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "inputs": {
- "low": {
- "id": "2118026f-1c64-41fa-ab6b-7532410f60ae",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "c12f312a-fdfd-4aca-9aa6-4c99bc70bd63",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "75552bad-6212-4ae7-96a7-68e666acea4c",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 1650,
- "y": 1600
- }
- }
- ],
- "edges": [
- {
- "id": "5ca498a4-c8c8-4580-a396-0c984317205d-f50624ce-82bf-41d0-bdf7-8aab11a80d48-collapsed",
- "source": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "collapsed"
- },
- {
- "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35-f50624ce-82bf-41d0-bdf7-8aab11a80d48-collapsed",
- "source": "eb8f6f8a-c7b1-4914-806e-045ee2717a35",
- "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "collapsed"
- },
- {
- "id": "reactflow__edge-771bdf6a-0813-4099-a5d8-921a138754d4image-f7564dd2-9539-47f2-ac13-190804461f4eimage",
- "source": "771bdf6a-0813-4099-a5d8-921a138754d4",
- "target": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4eimage-1d887701-df21-4966-ae6e-a7d82307d7bdimage",
- "source": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "target": "1d887701-df21-4966-ae6e-a7d82307d7bd",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dwidth-f50624ce-82bf-41d0-bdf7-8aab11a80d48width",
- "source": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "default",
- "sourceHandle": "width",
- "targetHandle": "width"
- },
- {
- "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dheight-f50624ce-82bf-41d0-bdf7-8aab11a80d48height",
- "source": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "default",
- "sourceHandle": "height",
- "targetHandle": "height"
- },
- {
- "id": "reactflow__edge-f50624ce-82bf-41d0-bdf7-8aab11a80d48noise-c3737554-8d87-48ff-a6f8-e71d2867f434noise",
- "source": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dlatents-c3737554-8d87-48ff-a6f8-e71d2867f434latents",
- "source": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-e8bf67fe-67de-4227-87eb-79e86afdfc74conditioning-c3737554-8d87-48ff-a6f8-e71d2867f434negative_conditioning",
- "source": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16bconditioning-c3737554-8d87-48ff-a6f8-e71d2867f434positive_conditioning",
- "source": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dclip-63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16bclip",
- "source": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "target": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dclip-e8bf67fe-67de-4227-87eb-79e86afdfc74clip",
- "source": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "target": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-1d887701-df21-4966-ae6e-a7d82307d7bdimage-ca1d020c-89a8-4958-880a-016d28775cfaimage",
- "source": "1d887701-df21-4966-ae6e-a7d82307d7bd",
- "target": "ca1d020c-89a8-4958-880a-016d28775cfa",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-ca1d020c-89a8-4958-880a-016d28775cfacontrol-c3737554-8d87-48ff-a6f8-e71d2867f434control",
- "source": "ca1d020c-89a8-4958-880a-016d28775cfa",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "control",
- "targetHandle": "control"
- },
- {
- "id": "reactflow__edge-c3737554-8d87-48ff-a6f8-e71d2867f434latents-3ed9b2ef-f4ec-40a7-94db-92e63b583ec0latents",
- "source": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "target": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dvae-3ed9b2ef-f4ec-40a7-94db-92e63b583ec0vae",
- "source": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "target": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4eimage-5ca498a4-c8c8-4580-a396-0c984317205dimage",
- "source": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "target": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dunet-c3737554-8d87-48ff-a6f8-e71d2867f434unet",
- "source": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "target": "c3737554-8d87-48ff-a6f8-e71d2867f434",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dvae-5ca498a4-c8c8-4580-a396-0c984317205dvae",
- "source": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "target": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-eb8f6f8a-c7b1-4914-806e-045ee2717a35value-f50624ce-82bf-41d0-bdf7-8aab11a80d48seed",
- "source": "eb8f6f8a-c7b1-4914-806e-045ee2717a35",
- "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/FaceMask.json b/docs/workflows/FaceMask.json
deleted file mode 100644
index 54c1f800b67..00000000000
--- a/docs/workflows/FaceMask.json
+++ /dev/null
@@ -1,1081 +0,0 @@
-{
- "name": "FaceMask",
- "author": "YMGenesis",
- "description": "Place an image with recognizable face(s) in Image Primitive, and write what sort of new face you want in the top prompt text box. See Notes for more info.",
- "version": "1.0",
- "contact": "YMGenesis on InvokeAI Discord",
- "tags": "facemask, facetools",
- "notes": "If you want to choose one face out of many, run the original image through FaceIdentifier and view its output to get the FaceID you want to change. Then, enter it into the \"Face Ids\" field on FaceMask. If changing many faces, but not all, enter the IDs you wish to change in a comma separated list (ex: 1,3,5 or 1, 3, 5). \n\nTo resemble the original face more when doing small touchups, change Denoise Start on Denoise Latents to a number closer to 1 (ex: 0.5-0.9). To create something fairly new, Denoise Start should be around 0.2-0.5. When using a regular model, anything around 0.2 and below might not create a face, but a new \"image\" inside the face area instead. Use an inpaint model in that case.\n\nAdjust X&Y Offsets on FaceMask to adjust the shape of the mask along those axes. Note: X&Y Offset changes will apply to all face masks in the mask image.",
- "exposedFields": [],
- "meta": {
- "version": "1.0.0"
- },
- "nodes": [
- {
- "id": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "type": "invocation",
- "data": {
- "id": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "id": "e3c5384f-3b73-45c4-bdd0-9f394819f33d",
- "name": "model",
- "type": "MainModelField",
- "fieldKind": "input",
- "label": "",
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "e578c176-1de1-4e36-bfc0-771f60d615da",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "output"
- },
- "clip": {
- "id": "54a97800-72ac-4951-94e5-2711ac139a07",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "output"
- },
- "vae": {
- "id": "814a8df5-7882-48a2-8ac1-8ff82d7c1b07",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 226,
- "position": {
- "x": 4103.832039728059,
- "y": 1987.4435345183065
- }
- },
- {
- "id": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "type": "invocation",
- "data": {
- "id": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "a4f25874-bc29-4900-abef-47701c805132",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "",
- "value": ""
- },
- "clip": {
- "id": "6464b46f-fc74-4917-88b0-3fd458fc11f0",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "65bea1c6-e3b6-4120-a546-d93ff5dd7765",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 261,
- "position": {
- "x": 4988.591898842789,
- "y": 1700.8901379603535
- }
- },
- {
- "id": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "type": "invocation",
- "data": {
- "id": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "fcb313f2-74ab-443d-a8a8-eb5f2a9b5f96",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "",
- "value": ""
- },
- "clip": {
- "id": "a278e13b-823a-4dda-96ca-44cf56f994dd",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "3c1f0f11-b8b0-444a-97ad-1cf80acf4bf7",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 261,
- "position": {
- "x": 4986.228031951785,
- "y": 1987.19695578231
- }
- },
- {
- "id": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "type": "invocation",
- "data": {
- "id": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "type": "noise",
- "inputs": {
- "seed": {
- "id": "9358ec7b-e575-40d9-af22-2d4786ba1aa7",
- "name": "seed",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "width": {
- "id": "8422b4ba-9d8e-41ac-bf08-1ea826859b46",
- "name": "width",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "height": {
- "id": "2467c79c-302b-4800-9efd-5bca58103322",
- "name": "height",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "use_cpu": {
- "id": "911c827e-a6af-4168-9f6d-cecc732938ad",
- "name": "use_cpu",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "9db55798-5c98-40f6-9015-56bfa8618f12",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "bc091210-946e-410e-8c70-e20982dd1ee7",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "4df04a89-43a2-441a-8f15-089350b36ea7",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 5398.389401611981,
- "y": 2019.4053462371755
- }
- },
- {
- "id": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "type": "invocation",
- "data": {
- "id": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "type": "rand_int",
- "inputs": {
- "low": {
- "id": "86e06b6e-7c84-40b0-9df2-12f966c3db4d",
- "name": "low",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "high": {
- "id": "5ee9adcc-d48c-4b9a-951a-6892d234acbc",
- "name": "high",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "b6c69841-ba37-43f0-8904-0026b1caf8ff",
- "name": "value",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 5386.304039775159,
- "y": 1979.791644235275
- }
- },
- {
- "id": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "type": "invocation",
- "data": {
- "id": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "type": "create_denoise_mask",
- "inputs": {
- "vae": {
- "id": "c7991df7-9f68-4b42-96ce-d795a8e2f714",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "3345a725-f5d5-4f47-9942-b1dfffbe5906",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask": {
- "id": "41fb1d83-1ca3-4299-b039-fb3b7c90f04e",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "32d8cf55-9910-4e09-8486-1c556a580a2d",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "e26410cc-54f3-44f2-a81c-a22a8dad6f24",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "denoise_mask": {
- "id": "9e1b78c0-5ac6-4632-91e9-831ce328237f",
- "name": "denoise_mask",
- "type": "DenoiseMaskField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 5009.179957658444,
- "y": 2346.7322639404283
- }
- },
- {
- "id": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "type": "invocation",
- "data": {
- "id": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "type": "image",
- "inputs": {
- "image": {
- "id": "6efba7ef-b986-4488-84ca-80f23f939ba8",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "image": {
- "id": "2512df7a-9981-4186-93ed-aa5405dc3057",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "68d2a9c6-43ff-49aa-989c-db6f5452134f",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "2cb4e505-7e9e-40bd-b402-1e7470167d30",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 4107.933245141945,
- "y": 2255.443448115275
- }
- },
- {
- "id": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "type": "invocation",
- "data": {
- "id": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "type": "i2l",
- "inputs": {
- "image": {
- "id": "4aef9c20-51be-47e4-bdc4-d449694d75e1",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "vae": {
- "id": "9507adad-df7c-447b-8aad-4d8d6f638420",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "2a100779-503a-4fb4-a2b5-507eb0f8328f",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "3dbf8dbc-4ad4-4451-89f4-99a59ec87453",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "latents": {
- "id": "215003b1-64dd-4c97-b5a4-7593d41ffd0e",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "dc9b2940-79e0-49b2-906c-05417a691175",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "605de93b-c1c7-409f-b059-24918a292bfc",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 5006.155303630073,
- "y": 2277.2727128782517
- }
- },
- {
- "id": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "type": "invocation",
- "data": {
- "id": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "type": "denoise_latents",
- "inputs": {
- "noise": {
- "id": "175b4d0a-3017-46e2-933f-c02f1cfb29b2",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "steps": {
- "id": "dd174b3e-3f6c-46cb-a703-3c6f3b3c72f1",
- "name": "steps",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 10
- },
- "cfg_scale": {
- "id": "0ea30aa7-8747-4c93-87e8-3c84e0dfd187",
- "name": "cfg_scale",
- "type": "FloatPolymorphic",
- "fieldKind": "input",
- "label": "",
- "value": 7.5
- },
- "denoising_start": {
- "id": "a6392edb-8895-41ed-918b-0ba8d2ac72ac",
- "name": "denoising_start",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "denoising_end": {
- "id": "1d1807cc-a24d-426e-9de5-a7e61d45c006",
- "name": "denoising_end",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 1
- },
- "scheduler": {
- "id": "947a5212-8923-4d5d-934c-dbc5879b9d07",
- "name": "scheduler",
- "type": "Scheduler",
- "fieldKind": "input",
- "label": "",
- "value": "euler"
- },
- "control": {
- "id": "585378b9-2686-4573-b762-3dc2d6179193",
- "name": "control",
- "type": "ControlPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "ip_adapter": {
- "id": "191f4687-fdcc-45da-859f-71fd5091a8bd",
- "name": "ip_adapter",
- "type": "IPAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "t2i_adapter": {
- "id": "39fc54ae-2141-4cab-9c01-c6c415f964cd",
- "name": "t2i_adapter",
- "type": "T2IAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "7f1a388e-8355-496c-a45d-fce5b8685a63",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "denoise_mask": {
- "id": "de922941-b2d8-4c57-92a7-201f9ddaf262",
- "name": "denoise_mask",
- "type": "DenoiseMaskField",
- "fieldKind": "input",
- "label": ""
- },
- "positive_conditioning": {
- "id": "2b42b4e8-4795-4fcc-bef1-c08cb8e25e0a",
- "name": "positive_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "negative_conditioning": {
- "id": "ff29b7d7-1bff-4aa9-b5c0-f8786a55023a",
- "name": "negative_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "unet": {
- "id": "0155f1cb-152b-4097-9395-afcc745c697b",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "latents": {
- "id": "03cfb327-02a1-4fbe-b7ce-b07fd501d2b8",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "3ffb87d7-b5a6-4b35-bdf1-2bb9b718d815",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "2c1f0588-943a-4fd1-b75b-48d04c944296",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.3.0"
- },
- "width": 320,
- "height": 646,
- "position": {
- "x": 5512.059705982663,
- "y": 2103.8364934988267
- }
- },
- {
- "id": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "type": "invocation",
- "data": {
- "id": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "type": "face_mask_detection",
- "inputs": {
- "metadata": {
- "id": "a56bf310-b5c3-4440-8ba6-79f5e434a9e6",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "11ece2fd-57ee-4504-b1f5-8d0c9332d785",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "face_ids": {
- "id": "c5eaafc3-70f4-4bcd-9df7-0d4cd26e1734",
- "name": "face_ids",
- "type": "string",
- "fieldKind": "input",
- "label": "",
- "value": ""
- },
- "minimum_confidence": {
- "id": "ab85fdbd-d61e-4584-8595-c1cea1ffb288",
- "name": "minimum_confidence",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0.5
- },
- "x_offset": {
- "id": "e381e04d-b54d-4457-9b6e-b3b554a8e343",
- "name": "x_offset",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "y_offset": {
- "id": "98e771c8-df97-4a57-a5d4-9601dac68338",
- "name": "y_offset",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "chunk": {
- "id": "ae4af045-99f9-4d84-81d2-438dc3d13b8d",
- "name": "chunk",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "invert_mask": {
- "id": "a6a0fe16-da45-46aa-9f85-9469fde40d71",
- "name": "invert_mask",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "7de8643a-c6b1-4260-b843-728c8d0fc6d4",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "fb1450b6-de42-465a-98d7-1dc93ceb20d7",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "cea5abb6-1584-440a-9ee2-1e4c926235e7",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- },
- "mask": {
- "id": "272e4224-1736-42de-895d-096309259ac7",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.2"
- },
- "width": 320,
- "height": 585,
- "position": {
- "x": 4559.385043470649,
- "y": 2082.7157021692556
- }
- },
- {
- "id": "e4681270-ea7e-4063-9116-880408854eee",
- "type": "invocation",
- "data": {
- "id": "e4681270-ea7e-4063-9116-880408854eee",
- "type": "l2i",
- "inputs": {
- "metadata": {
- "id": "1632b6ac-605d-42a7-853c-65539a6f664e",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "9986c874-6d4b-47fc-895a-88933ef2b473",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "vae": {
- "id": "d5842416-e575-4e35-a5d0-fd1ce4401b52",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "6a84e45e-5f54-4952-9285-9dedc6d056d5",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "2fa37674-8685-4ec4-87d9-d4683131d79c",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "b1856446-1b03-4825-aae0-0859e27c3c8c",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "c95d92f6-2416-4fc8-a542-998c7c3fac73",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "7033a118-3b20-4c25-8450-e15d7fc8657c",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 5941.909912847396,
- "y": 2111.4771842290065
- }
- },
- {
- "id": "7bc3c331-4658-46fd-8736-fe3043fcd9d1",
- "type": "invocation",
- "data": {
- "id": "7bc3c331-4658-46fd-8736-fe3043fcd9d1",
- "type": "color_correct",
- "inputs": {
- "metadata": {
- "id": "203d08d1-586f-47c9-95a1-2afa1db23751",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "50bc42c6-c3b8-44c4-89fe-ef2edd9b67f4",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "reference": {
- "id": "2eef99df-b1f8-441c-a316-466a46812df0",
- "name": "reference",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask": {
- "id": "6cfde62b-e6fc-4f13-91dc-e679b26ec04b",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask_blur_radius": {
- "id": "51125aec-bbcb-4ae0-b618-a9bc632a5a86",
- "name": "mask_blur_radius",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 8
- }
- },
- "outputs": {
- "image": {
- "id": "4c82dcb2-1c55-4b6d-a42c-061478881393",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "b7d23fea-e361-468d-96e7-9b243592e904",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "d056d182-d261-4037-bb5e-caec9bda9ca6",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 396,
- "position": {
- "x": 6399.947577155154,
- "y": 2127.7477011465667
- }
- }
- ],
- "edges": [
- {
- "source": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "id": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68-098898c8-7a20-4d78-9363-296d42e3d8da-collapsed",
- "type": "collapsed"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "id": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d-098898c8-7a20-4d78-9363-296d42e3d8da-collapsed",
- "type": "collapsed"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "clip",
- "target": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "targetHandle": "clip",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036clip-fb7e72d9-51cb-432a-b511-c6c608d07413clip",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "clip",
- "target": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "targetHandle": "clip",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036clip-7c4e5071-5b76-4d42-b340-68b52c5ded7aclip",
- "type": "default"
- },
- {
- "source": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "sourceHandle": "value",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "seed",
- "id": "reactflow__edge-27dd9fc3-8c6e-4602-8754-e9ca2f478d68value-098898c8-7a20-4d78-9363-296d42e3d8daseed",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-bcbdc4ea-1fad-40d4-8632-70f84116f4b6vae",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-a6d08bcb-0b52-4dd8-9247-8b6480238c6dvae",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "width",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "width",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dwidth-098898c8-7a20-4d78-9363-296d42e3d8dawidth",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "height",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "height",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dheight-098898c8-7a20-4d78-9363-296d42e3d8daheight",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "unet",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "unet",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036unet-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9unet",
- "type": "default"
- },
- {
- "source": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "sourceHandle": "conditioning",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "negative_conditioning",
- "id": "reactflow__edge-7c4e5071-5b76-4d42-b340-68b52c5ded7aconditioning-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9negative_conditioning",
- "type": "default"
- },
- {
- "source": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "sourceHandle": "conditioning",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "positive_conditioning",
- "id": "reactflow__edge-fb7e72d9-51cb-432a-b511-c6c608d07413conditioning-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9positive_conditioning",
- "type": "default"
- },
- {
- "source": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "sourceHandle": "noise",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "noise",
- "id": "reactflow__edge-098898c8-7a20-4d78-9363-296d42e3d8danoise-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9noise",
- "type": "default"
- },
- {
- "source": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "sourceHandle": "denoise_mask",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "denoise_mask",
- "id": "reactflow__edge-bcbdc4ea-1fad-40d4-8632-70f84116f4b6denoise_mask-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9denoise_mask",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "latents",
- "target": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "targetHandle": "latents",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dlatents-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9latents",
- "type": "default"
- },
- {
- "source": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "sourceHandle": "image",
- "target": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "targetHandle": "image",
- "id": "reactflow__edge-3fac3aa6-910a-4a90-a8b6-5b7e1611efbaimage-eb725b0b-1fa6-4f79-aedb-52c19afcfad9image",
- "type": "default"
- },
- {
- "source": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "sourceHandle": "image",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "image",
- "id": "reactflow__edge-eb725b0b-1fa6-4f79-aedb-52c19afcfad9image-bcbdc4ea-1fad-40d4-8632-70f84116f4b6image",
- "type": "default"
- },
- {
- "source": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "sourceHandle": "mask",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "mask",
- "id": "reactflow__edge-eb725b0b-1fa6-4f79-aedb-52c19afcfad9mask-bcbdc4ea-1fad-40d4-8632-70f84116f4b6mask",
- "type": "default"
- },
- {
- "source": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "sourceHandle": "image",
- "target": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "targetHandle": "image",
- "id": "reactflow__edge-eb725b0b-1fa6-4f79-aedb-52c19afcfad9image-a6d08bcb-0b52-4dd8-9247-8b6480238c6dimage",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "e4681270-ea7e-4063-9116-880408854eee",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-e4681270-ea7e-4063-9116-880408854eeevae",
- "type": "default"
- },
- {
- "source": "d14b4d95-bf74-4ec5-827b-4c9e797c7ae9",
- "sourceHandle": "latents",
- "target": "e4681270-ea7e-4063-9116-880408854eee",
- "targetHandle": "latents",
- "id": "reactflow__edge-d14b4d95-bf74-4ec5-827b-4c9e797c7ae9latents-e4681270-ea7e-4063-9116-880408854eeelatents",
- "type": "default"
- },
- {
- "source": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "sourceHandle": "image",
- "target": "7bc3c331-4658-46fd-8736-fe3043fcd9d1",
- "targetHandle": "reference",
- "id": "reactflow__edge-3fac3aa6-910a-4a90-a8b6-5b7e1611efbaimage-7bc3c331-4658-46fd-8736-fe3043fcd9d1reference",
- "type": "default"
- },
- {
- "source": "e4681270-ea7e-4063-9116-880408854eee",
- "sourceHandle": "image",
- "target": "7bc3c331-4658-46fd-8736-fe3043fcd9d1",
- "targetHandle": "image",
- "id": "reactflow__edge-e4681270-ea7e-4063-9116-880408854eeeimage-7bc3c331-4658-46fd-8736-fe3043fcd9d1image",
- "type": "default"
- },
- {
- "source": "eb725b0b-1fa6-4f79-aedb-52c19afcfad9",
- "sourceHandle": "mask",
- "target": "7bc3c331-4658-46fd-8736-fe3043fcd9d1",
- "targetHandle": "mask",
- "id": "reactflow__edge-eb725b0b-1fa6-4f79-aedb-52c19afcfad9mask-7bc3c331-4658-46fd-8736-fe3043fcd9d1mask",
- "type": "default"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/FaceOff_FaceScale2x.json b/docs/workflows/FaceOff_FaceScale2x.json
deleted file mode 100644
index d1707e5e732..00000000000
--- a/docs/workflows/FaceOff_FaceScale2x.json
+++ /dev/null
@@ -1,1451 +0,0 @@
-{
- "name": "FaceOff_FaceScale2x",
- "author": "YMGenesis",
- "description": "21 September 2023\n\nPlace an image with recognizable face(s) in Image Primitive, and write what sort of new face you want in the top prompt text box. The face (and mask) will be scaled by 2 factor to allow for more details when generating. It'll then be downscaled by half before being pasted back onto the original image. See Notes for more info.",
- "version": "1.0",
- "contact": "YMGenesis on InvokeAI Discord",
- "tags": "faceoff, facetools",
- "notes": "If you want to choose one face out of many, run the original image through FaceIdentifier and view its output to get the FaceID you want to change. Then, enter it into the \"Face Id\" field on FaceOff.\n\nTo resemble the original face more when doing small touchups, change Denoise Start on Denoise Latents to a number closer to 1 (ex: 0.5-0.9). To create something fairly new, Denoise Start should be around 0.2-0.5. When using a regular model, anything around 0.2 and below might not create a face, but a new \"image\" inside the face area instead. Use an inpaint model in that case.\n\nAdjust X&Y Offsets on FaceOff to adjust the shape of the mask along those axes. Adjust Padding to zoom the bounding box out (positive integers) and in (negative integers). Zooming out will give more context to the diffusion process, resulting in a face more likely to exist in that image.",
- "exposedFields": [],
- "meta": {
- "version": "1.0.0"
- },
- "nodes": [
- {
- "id": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "type": "invocation",
- "data": {
- "id": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "id": "e3c5384f-3b73-45c4-bdd0-9f394819f33d",
- "name": "model",
- "type": "MainModelField",
- "fieldKind": "input",
- "label": "",
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "e578c176-1de1-4e36-bfc0-771f60d615da",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "output"
- },
- "clip": {
- "id": "54a97800-72ac-4951-94e5-2711ac139a07",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "output"
- },
- "vae": {
- "id": "814a8df5-7882-48a2-8ac1-8ff82d7c1b07",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 226,
- "position": {
- "x": 4625,
- "y": 1275
- }
- },
- {
- "id": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "type": "invocation",
- "data": {
- "id": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "a4f25874-bc29-4900-abef-47701c805132",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "",
- "value": ""
- },
- "clip": {
- "id": "6464b46f-fc74-4917-88b0-3fd458fc11f0",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "65bea1c6-e3b6-4120-a546-d93ff5dd7765",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 259,
- "position": {
- "x": 5025,
- "y": 1275
- }
- },
- {
- "id": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "type": "invocation",
- "data": {
- "id": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "fcb313f2-74ab-443d-a8a8-eb5f2a9b5f96",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "",
- "value": ""
- },
- "clip": {
- "id": "a278e13b-823a-4dda-96ca-44cf56f994dd",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "3c1f0f11-b8b0-444a-97ad-1cf80acf4bf7",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 259,
- "position": {
- "x": 5025,
- "y": 1550
- }
- },
- {
- "id": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "type": "invocation",
- "data": {
- "id": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "type": "noise",
- "inputs": {
- "seed": {
- "id": "9358ec7b-e575-40d9-af22-2d4786ba1aa7",
- "name": "seed",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "width": {
- "id": "8422b4ba-9d8e-41ac-bf08-1ea826859b46",
- "name": "width",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "height": {
- "id": "2467c79c-302b-4800-9efd-5bca58103322",
- "name": "height",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "use_cpu": {
- "id": "911c827e-a6af-4168-9f6d-cecc732938ad",
- "name": "use_cpu",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "9db55798-5c98-40f6-9015-56bfa8618f12",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "bc091210-946e-410e-8c70-e20982dd1ee7",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "4df04a89-43a2-441a-8f15-089350b36ea7",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 389,
- "position": {
- "x": 5425,
- "y": 1525
- }
- },
- {
- "id": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "type": "invocation",
- "data": {
- "id": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "type": "rand_int",
- "inputs": {
- "low": {
- "id": "86e06b6e-7c84-40b0-9df2-12f966c3db4d",
- "name": "low",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "high": {
- "id": "5ee9adcc-d48c-4b9a-951a-6892d234acbc",
- "name": "high",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "b6c69841-ba37-43f0-8904-0026b1caf8ff",
- "name": "value",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 218,
- "position": {
- "x": 5425,
- "y": 1275
- }
- },
- {
- "id": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "type": "invocation",
- "data": {
- "id": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "type": "create_denoise_mask",
- "inputs": {
- "vae": {
- "id": "c7991df7-9f68-4b42-96ce-d795a8e2f714",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "3345a725-f5d5-4f47-9942-b1dfffbe5906",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask": {
- "id": "41fb1d83-1ca3-4299-b039-fb3b7c90f04e",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "32d8cf55-9910-4e09-8486-1c556a580a2d",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "e26410cc-54f3-44f2-a81c-a22a8dad6f24",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "denoise_mask": {
- "id": "9e1b78c0-5ac6-4632-91e9-831ce328237f",
- "name": "denoise_mask",
- "type": "DenoiseMaskField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 318,
- "position": {
- "x": 5025,
- "y": 2175
- }
- },
- {
- "id": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "type": "invocation",
- "data": {
- "id": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "type": "image",
- "inputs": {
- "image": {
- "id": "6efba7ef-b986-4488-84ca-80f23f939ba8",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "image": {
- "id": "2512df7a-9981-4186-93ed-aa5405dc3057",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "68d2a9c6-43ff-49aa-989c-db6f5452134f",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "2cb4e505-7e9e-40bd-b402-1e7470167d30",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 4625,
- "y": 1525
- }
- },
- {
- "id": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "type": "invocation",
- "data": {
- "id": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "type": "i2l",
- "inputs": {
- "image": {
- "id": "4aef9c20-51be-47e4-bdc4-d449694d75e1",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "vae": {
- "id": "9507adad-df7c-447b-8aad-4d8d6f638420",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "2a100779-503a-4fb4-a2b5-507eb0f8328f",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "3dbf8dbc-4ad4-4451-89f4-99a59ec87453",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "latents": {
- "id": "215003b1-64dd-4c97-b5a4-7593d41ffd0e",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "dc9b2940-79e0-49b2-906c-05417a691175",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "605de93b-c1c7-409f-b059-24918a292bfc",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 325,
- "position": {
- "x": 5025,
- "y": 1825
- }
- },
- {
- "id": "01a35dfd-b4bd-4901-8088-49972eac7582",
- "type": "invocation",
- "data": {
- "id": "01a35dfd-b4bd-4901-8088-49972eac7582",
- "type": "l2i",
- "inputs": {
- "metadata": {
- "id": "ce479dbf-d12f-43e7-9047-ec0e6bd838a7",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "0d3f0abc-5c60-495e-af10-8136b25532d0",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "vae": {
- "id": "c0563226-c977-456a-8cf5-2873ab56a9f4",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "f909d67c-f789-4daa-8528-50113bba78ea",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "d7c26ac3-df0f-47ab-aa91-0c5bfbe1ea38",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "380a7af7-bba5-434b-9996-47b8194d69d4",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "a0f2a9cf-77f6-42a5-801d-30f635d1de26",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "890ec07c-9bc0-46f6-b962-8a6fd149d562",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 5810.2137275110845,
- "y": 1270.641572039504
- }
- },
- {
- "id": "43c00342-9ca3-498a-8635-c4c716e32d5f",
- "type": "invocation",
- "data": {
- "id": "43c00342-9ca3-498a-8635-c4c716e32d5f",
- "type": "img_scale",
- "inputs": {
- "metadata": {
- "id": "d35fa75e-439b-4fda-9064-4ed73f378de6",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "e8c46885-2ec1-4fb9-8d34-07534e62ec97",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "scale_factor": {
- "id": "94b04846-9f70-44ac-ae82-99af13632255",
- "name": "scale_factor",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 2
- },
- "resample_mode": {
- "id": "2fbcd8bb-6668-4661-868a-1452f0e73e6d",
- "name": "resample_mode",
- "type": "enum",
- "fieldKind": "input",
- "label": "",
- "value": "bicubic"
- }
- },
- "outputs": {
- "image": {
- "id": "96600931-0da3-4c68-842f-d06f2d93f383",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "ff158681-580c-4a51-a2c8-c4bd2058a011",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "2a332d0e-4442-4a6e-8cf9-5e2e42f713c6",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 339,
- "position": {
- "x": 5808.235568293352,
- "y": 1551.540180957498
- }
- },
- {
- "id": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "type": "invocation",
- "data": {
- "id": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "type": "color_correct",
- "inputs": {
- "metadata": {
- "id": "89536a0d-1c62-4975-ada9-33f359837481",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "10d7c1da-e8e1-4226-ad9f-1bde9816c118",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "reference": {
- "id": "9c7354ed-33dc-462a-bc57-717082eb3f45",
- "name": "reference",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask": {
- "id": "03aee643-6873-421c-8caf-1e05e85f6a9d",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask_blur_radius": {
- "id": "2d2dffa6-f719-4ec9-a201-c07a3a914c77",
- "name": "mask_blur_radius",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 8
- }
- },
- "outputs": {
- "image": {
- "id": "b652c45e-e797-47d2-9175-aa77ede6a8f5",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "385015ed-2c6b-4b72-adda-ba5639841352",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "fe6632e0-560c-4c56-9b52-6e5e5d7eb642",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 396,
- "position": {
- "x": 5806.25740907562,
- "y": 1903.6525217138576
- }
- },
- {
- "id": "4e11665c-b932-493d-ab4f-019bed730b47",
- "type": "invocation",
- "data": {
- "id": "4e11665c-b932-493d-ab4f-019bed730b47",
- "type": "img_paste",
- "inputs": {
- "metadata": {
- "id": "6f365d9d-bf84-4053-b936-8035a4c7c991",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "base_image": {
- "id": "fb323f35-d1fd-4d20-b145-9a3db7a57a28",
- "name": "base_image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "ea76a063-ec51-4b2b-bfa8-8df9c64ecc2e",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "mask": {
- "id": "9b3f194a-d7f1-45f3-989a-6b2096dd3fd6",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "x": {
- "id": "d74b8e26-bc59-4a27-bb00-f769b81e8ef0",
- "name": "x",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "y": {
- "id": "6ada744a-8f67-49c6-a8b6-df03b3397d73",
- "name": "y",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "crop": {
- "id": "dec1e8e1-2fb0-43e8-9ddf-0eb478e5e567",
- "name": "crop",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "4a01e54d-9a7b-458d-aa87-640ce8c5cd62",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "be903c7c-b867-4938-9a0e-de8a6676ae89",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "7dbb23a1-bedc-418c-8c27-09cf24e0e777",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1"
- },
- "width": 320,
- "height": 504,
- "position": {
- "x": 5810.103371182836,
- "y": 2315.9071582023707
- }
- },
- {
- "id": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "type": "invocation",
- "data": {
- "id": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "type": "denoise_latents",
- "inputs": {
- "positive_conditioning": {
- "id": "1039d676-b54f-4848-903d-a5e3eef84781",
- "name": "positive_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "negative_conditioning": {
- "id": "52e4b032-e32f-48b1-ac02-b17f0dff78f6",
- "name": "negative_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "noise": {
- "id": "7c6c35bb-576c-42fa-8191-169afff13d73",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "steps": {
- "id": "e0df62c7-edb8-4777-8ca1-c7a6fb30f6a2",
- "name": "steps",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 10
- },
- "cfg_scale": {
- "id": "e1a87d18-671d-4a6e-a528-7d4376500211",
- "name": "cfg_scale",
- "type": "FloatPolymorphic",
- "fieldKind": "input",
- "label": "",
- "value": 7.5
- },
- "denoising_start": {
- "id": "60ab8906-162c-4817-bd46-f5d7a93aa213",
- "name": "denoising_start",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "denoising_end": {
- "id": "c8f73ade-d5a4-4e69-8462-c0f9ee3dfb96",
- "name": "denoising_end",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 1
- },
- "scheduler": {
- "id": "e2872b56-4250-4a0d-86b3-37c60485fdc7",
- "name": "scheduler",
- "type": "Scheduler",
- "fieldKind": "input",
- "label": "",
- "value": "euler"
- },
- "unet": {
- "id": "b1f93d41-b399-4774-a913-4a0c0c6d5f66",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "input",
- "label": ""
- },
- "control": {
- "id": "89315aa7-95d7-463b-86fd-a8064092503c",
- "name": "control",
- "type": "ControlPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "ip_adapter": {
- "id": "370ded4f-ef23-4741-9e4c-1f3d1d429cfd",
- "name": "ip_adapter",
- "type": "IPAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "t2i_adapter": {
- "id": "dac77b99-0cc9-46c5-9d17-66ddbabdb7f8",
- "name": "t2i_adapter",
- "type": "T2IAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "229e7492-abfd-47cb-9218-67c1ba75c4b4",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "denoise_mask": {
- "id": "1248eb2e-d362-446c-8ffc-ddab52b7c20f",
- "name": "denoise_mask",
- "type": "DenoiseMaskField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "latents": {
- "id": "de5c8012-df50-4fae-a4b7-ca09aaa181ca",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "ba1e5e4f-3ba9-467b-b80d-3c85aa97afab",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "b0fa6029-ede9-45be-b1ec-b1ec469bfeb6",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.4.0"
- },
- "width": 320,
- "height": 646,
- "position": {
- "x": 5468.65700494231,
- "y": 1926.1754472409636
- }
- },
- {
- "id": "98048fa7-dd08-49ec-92b8-76c9017e5444",
- "type": "invocation",
- "data": {
- "id": "98048fa7-dd08-49ec-92b8-76c9017e5444",
- "type": "img_scale",
- "inputs": {
- "metadata": {
- "id": "02fa128c-3d3b-4083-aa73-fb7acbfb072a",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "8008b9db-bd61-4a89-b2df-de444961bc5b",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "scale_factor": {
- "id": "45268d5f-b8f9-49ee-b770-a6876100878a",
- "name": "scale_factor",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 2
- },
- "resample_mode": {
- "id": "645b99b1-fd64-429b-b61d-caf739830efa",
- "name": "resample_mode",
- "type": "enum",
- "fieldKind": "input",
- "label": "",
- "value": "bicubic"
- }
- },
- "outputs": {
- "image": {
- "id": "ff088e78-dd52-4605-a14d-d10d7c0015a5",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "b0bede16-55d6-4fee-8709-6bce92118431",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "88dc53cd-9d12-4f95-b23a-382b3db92db9",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 339,
- "position": {
- "x": 5026.491824134117,
- "y": 2513.056664333261
- }
- },
- {
- "id": "894b9ca6-e7bc-428e-87e9-cce64095bce9",
- "type": "invocation",
- "data": {
- "id": "894b9ca6-e7bc-428e-87e9-cce64095bce9",
- "type": "img_scale",
- "inputs": {
- "metadata": {
- "id": "ae97ac6c-6dc7-4c01-955a-ae98a608db4b",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "56d9afd5-b1c6-4b62-b332-a5585e863000",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "scale_factor": {
- "id": "cdeeecb8-8f12-4d2b-b961-4d18cca858de",
- "name": "scale_factor",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 2
- },
- "resample_mode": {
- "id": "b120b6a1-dbdb-4989-80fb-af0a682e124c",
- "name": "resample_mode",
- "type": "enum",
- "fieldKind": "input",
- "label": "",
- "value": "bicubic"
- }
- },
- "outputs": {
- "image": {
- "id": "7888a231-4f89-4ad7-a216-d5664a96eb7d",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "9030b8fd-4963-4bc7-86a2-5a964443fc41",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "4beb6975-4ec2-4c76-8916-81331a2d2230",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 339,
- "position": {
- "x": 4624.131880974906,
- "y": 2614.5694940293906
- }
- },
- {
- "id": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "type": "invocation",
- "data": {
- "id": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "type": "face_off",
- "inputs": {
- "metadata": {
- "id": "4e7eb36f-3742-49d9-91b4-092b955ef588",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "image": {
- "id": "003c3ff3-d0d0-464e-85ed-fb4df75fd513",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "face_id": {
- "id": "9281074a-be25-4e43-8b80-9504b2365999",
- "name": "face_id",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "minimum_confidence": {
- "id": "808efdd1-41d5-4c3c-9ea3-0a1854503087",
- "name": "minimum_confidence",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0.5
- },
- "x_offset": {
- "id": "fd4fedf5-7fba-4687-864b-bfd018c296cf",
- "name": "x_offset",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "y_offset": {
- "id": "3d64697c-707e-404b-8261-9665c78f686d",
- "name": "y_offset",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "padding": {
- "id": "1f147de3-8fde-4bcc-8c68-a6617feed334",
- "name": "padding",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "chunk": {
- "id": "e60fae93-2056-4621-a6bf-d0248df6f538",
- "name": "chunk",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "1c6aa0cf-4bb8-4236-b92e-a862036c9522",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "8dac27bd-7d72-471b-a310-bb7dae686a0b",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "4bfb3364-fe37-4fb8-b8a0-75d1038ce726",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- },
- "mask": {
- "id": "221c6022-5314-4f8f-afb0-4b00ceea8ecd",
- "name": "mask",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "x": {
- "id": "657fa37b-6e9c-4c04-b5d2-e3e659fcb0f3",
- "name": "x",
- "type": "integer",
- "fieldKind": "output"
- },
- "y": {
- "id": "bc5cf72e-0389-4b8c-b886-40d145b6b73a",
- "name": "y",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.2"
- },
- "width": 320,
- "height": 656,
- "position": {
- "x": 4618.594817536934,
- "y": 1836.6663791833205
- }
- }
- ],
- "edges": [
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "clip",
- "target": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "targetHandle": "clip",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036clip-fb7e72d9-51cb-432a-b511-c6c608d07413clip",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "clip",
- "target": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "targetHandle": "clip",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036clip-7c4e5071-5b76-4d42-b340-68b52c5ded7aclip",
- "type": "default"
- },
- {
- "source": "27dd9fc3-8c6e-4602-8754-e9ca2f478d68",
- "sourceHandle": "value",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "seed",
- "id": "reactflow__edge-27dd9fc3-8c6e-4602-8754-e9ca2f478d68value-098898c8-7a20-4d78-9363-296d42e3d8daseed",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-bcbdc4ea-1fad-40d4-8632-70f84116f4b6vae",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-a6d08bcb-0b52-4dd8-9247-8b6480238c6dvae",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "width",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "width",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dwidth-098898c8-7a20-4d78-9363-296d42e3d8dawidth",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "height",
- "target": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "targetHandle": "height",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dheight-098898c8-7a20-4d78-9363-296d42e3d8daheight",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "vae",
- "target": "01a35dfd-b4bd-4901-8088-49972eac7582",
- "targetHandle": "vae",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036vae-01a35dfd-b4bd-4901-8088-49972eac7582vae",
- "type": "default"
- },
- {
- "source": "01a35dfd-b4bd-4901-8088-49972eac7582",
- "sourceHandle": "image",
- "target": "43c00342-9ca3-498a-8635-c4c716e32d5f",
- "targetHandle": "image",
- "id": "reactflow__edge-01a35dfd-b4bd-4901-8088-49972eac7582image-43c00342-9ca3-498a-8635-c4c716e32d5fimage",
- "type": "default"
- },
- {
- "source": "43c00342-9ca3-498a-8635-c4c716e32d5f",
- "sourceHandle": "image",
- "target": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "targetHandle": "image",
- "id": "reactflow__edge-43c00342-9ca3-498a-8635-c4c716e32d5fimage-0c71919b-a030-44fb-8c09-1baf37088d20image",
- "type": "default"
- },
- {
- "source": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "sourceHandle": "image",
- "target": "4e11665c-b932-493d-ab4f-019bed730b47",
- "targetHandle": "base_image",
- "id": "reactflow__edge-3fac3aa6-910a-4a90-a8b6-5b7e1611efbaimage-4e11665c-b932-493d-ab4f-019bed730b47base_image",
- "type": "default"
- },
- {
- "source": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "sourceHandle": "image",
- "target": "4e11665c-b932-493d-ab4f-019bed730b47",
- "targetHandle": "image",
- "id": "reactflow__edge-0c71919b-a030-44fb-8c09-1baf37088d20image-4e11665c-b932-493d-ab4f-019bed730b47image",
- "type": "default"
- },
- {
- "source": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "sourceHandle": "latents",
- "target": "01a35dfd-b4bd-4901-8088-49972eac7582",
- "targetHandle": "latents",
- "id": "reactflow__edge-9a6a35cd-5c05-4df1-81bf-e40a1954c618latents-01a35dfd-b4bd-4901-8088-49972eac7582latents",
- "type": "default"
- },
- {
- "source": "c9897be0-7f59-4388-816d-86cb72cc4036",
- "sourceHandle": "unet",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "unet",
- "id": "reactflow__edge-c9897be0-7f59-4388-816d-86cb72cc4036unet-9a6a35cd-5c05-4df1-81bf-e40a1954c618unet",
- "type": "default"
- },
- {
- "source": "7c4e5071-5b76-4d42-b340-68b52c5ded7a",
- "sourceHandle": "conditioning",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "negative_conditioning",
- "id": "reactflow__edge-7c4e5071-5b76-4d42-b340-68b52c5ded7aconditioning-9a6a35cd-5c05-4df1-81bf-e40a1954c618negative_conditioning",
- "type": "default"
- },
- {
- "source": "fb7e72d9-51cb-432a-b511-c6c608d07413",
- "sourceHandle": "conditioning",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "positive_conditioning",
- "id": "reactflow__edge-fb7e72d9-51cb-432a-b511-c6c608d07413conditioning-9a6a35cd-5c05-4df1-81bf-e40a1954c618positive_conditioning",
- "type": "default"
- },
- {
- "source": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "sourceHandle": "denoise_mask",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "denoise_mask",
- "id": "reactflow__edge-bcbdc4ea-1fad-40d4-8632-70f84116f4b6denoise_mask-9a6a35cd-5c05-4df1-81bf-e40a1954c618denoise_mask",
- "type": "default"
- },
- {
- "source": "098898c8-7a20-4d78-9363-296d42e3d8da",
- "sourceHandle": "noise",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "noise",
- "id": "reactflow__edge-098898c8-7a20-4d78-9363-296d42e3d8danoise-9a6a35cd-5c05-4df1-81bf-e40a1954c618noise",
- "type": "default"
- },
- {
- "source": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "sourceHandle": "latents",
- "target": "9a6a35cd-5c05-4df1-81bf-e40a1954c618",
- "targetHandle": "latents",
- "id": "reactflow__edge-a6d08bcb-0b52-4dd8-9247-8b6480238c6dlatents-9a6a35cd-5c05-4df1-81bf-e40a1954c618latents",
- "type": "default"
- },
- {
- "source": "98048fa7-dd08-49ec-92b8-76c9017e5444",
- "sourceHandle": "image",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "mask",
- "id": "reactflow__edge-98048fa7-dd08-49ec-92b8-76c9017e5444image-bcbdc4ea-1fad-40d4-8632-70f84116f4b6mask",
- "type": "default"
- },
- {
- "source": "894b9ca6-e7bc-428e-87e9-cce64095bce9",
- "sourceHandle": "image",
- "target": "a6d08bcb-0b52-4dd8-9247-8b6480238c6d",
- "targetHandle": "image",
- "id": "reactflow__edge-894b9ca6-e7bc-428e-87e9-cce64095bce9image-a6d08bcb-0b52-4dd8-9247-8b6480238c6dimage",
- "type": "default"
- },
- {
- "source": "894b9ca6-e7bc-428e-87e9-cce64095bce9",
- "sourceHandle": "image",
- "target": "bcbdc4ea-1fad-40d4-8632-70f84116f4b6",
- "targetHandle": "image",
- "id": "reactflow__edge-894b9ca6-e7bc-428e-87e9-cce64095bce9image-bcbdc4ea-1fad-40d4-8632-70f84116f4b6image",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "image",
- "target": "894b9ca6-e7bc-428e-87e9-cce64095bce9",
- "targetHandle": "image",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfimage-894b9ca6-e7bc-428e-87e9-cce64095bce9image",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "image",
- "target": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "targetHandle": "reference",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfimage-0c71919b-a030-44fb-8c09-1baf37088d20reference",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "mask",
- "target": "98048fa7-dd08-49ec-92b8-76c9017e5444",
- "targetHandle": "image",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfmask-98048fa7-dd08-49ec-92b8-76c9017e5444image",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "mask",
- "target": "0c71919b-a030-44fb-8c09-1baf37088d20",
- "targetHandle": "mask",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfmask-0c71919b-a030-44fb-8c09-1baf37088d20mask",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "x",
- "target": "4e11665c-b932-493d-ab4f-019bed730b47",
- "targetHandle": "x",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfx-4e11665c-b932-493d-ab4f-019bed730b47x",
- "type": "default"
- },
- {
- "source": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "sourceHandle": "y",
- "target": "4e11665c-b932-493d-ab4f-019bed730b47",
- "targetHandle": "y",
- "id": "reactflow__edge-efea8306-de20-418f-806c-31ae4e5eb6bfy-4e11665c-b932-493d-ab4f-019bed730b47y",
- "type": "default"
- },
- {
- "source": "3fac3aa6-910a-4a90-a8b6-5b7e1611efba",
- "sourceHandle": "image",
- "target": "efea8306-de20-418f-806c-31ae4e5eb6bf",
- "targetHandle": "image",
- "id": "reactflow__edge-3fac3aa6-910a-4a90-a8b6-5b7e1611efbaimage-efea8306-de20-418f-806c-31ae4e5eb6bfimage",
- "type": "default"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/Face_Detailer_with_IP-Adapter_and_Canny.json b/docs/workflows/Face_Detailer_with_IP-Adapter_and_Canny.json
deleted file mode 100644
index 6f392d6a7b5..00000000000
--- a/docs/workflows/Face_Detailer_with_IP-Adapter_and_Canny.json
+++ /dev/null
@@ -1,2930 +0,0 @@
-{
- "id": "972fc0e9-a13f-4658-86b9-aef100d124ba",
- "name": "Face Detailer with IP-Adapter & Canny (See Note)",
- "author": "kosmoskatten",
- "description": "A workflow to add detail to and improve faces. This workflow is most effective when used with a model that creates realistic outputs. For best results, use this image as the blur mask: https://i.imgur.com/Gxi61zP.png",
- "version": "1.0.0",
- "contact": "invoke@invoke.ai",
- "tags": "face detailer, IP-Adapter, Canny",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05",
- "fieldName": "value"
- },
- {
- "nodeId": "64712037-92e8-483f-9f6e-87588539c1b8",
- "fieldName": "value"
- },
- {
- "nodeId": "77da4e4d-5778-4469-8449-ffed03d54bdb",
- "fieldName": "radius"
- },
- {
- "nodeId": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "fieldName": "value"
- },
- {
- "nodeId": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "fieldName": "image"
- },
- {
- "nodeId": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "fieldName": "image"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "44f2c190-eb03-460d-8d11-a94d13b33f19",
- "type": "invocation",
- "data": {
- "id": "44f2c190-eb03-460d-8d11-a94d13b33f19",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "916b229a-38e1-45a2-a433-cca97495b143",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "ae9aeb1a-4ebd-4bc3-b6e6-a8c9adca01f6",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "4d59bad1-99a9-43e2-bdb4-7a0f3dd5b787",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 2575,
- "y": -250
- }
- },
- {
- "id": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "type": "invocation",
- "data": {
- "id": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "type": "img_resize",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "48ff6f70-380c-4e19-acf7-91063cfff8a8",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "2a348a03-07a3-4a97-93c8-46051045b32f",
- "name": "image",
- "fieldKind": "input",
- "label": "Blur Mask",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "6b95969c-ca73-4b54-815d-7aae305a67bd",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "af83c526-c730-4a34-8f73-80f443fecc05",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "resample_mode": {
- "id": "43f7d6b5-ebe0-43c4-bf5d-8fb7fdc40a3f",
- "name": "resample_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "lanczos"
- }
- },
- "outputs": {
- "image": {
- "id": "e492b013-615d-4dfd-b0d8-7df7b5ba9a9d",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "05f829b3-c253-495a-b1ad-9906c0833e49",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "65d5d662-2527-4f3c-8a87-0a7d9d4486de",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 397,
- "position": {
- "x": 4675,
- "y": 625
- }
- },
- {
- "id": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "type": "invocation",
- "data": {
- "id": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "type": "image",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "729c571b-d5a0-4b53-8f50-5e11eb744f66",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- },
- "value": {
- "image_name": "42a524fd-ed77-46e2-b52f-40375633f357.png"
- }
- }
- },
- "outputs": {
- "image": {
- "id": "3632a144-58d6-4447-bafc-e4f7d6ca96bf",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "30faefcc-81a1-445b-a3fe-0110ceb56772",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "d173d225-849a-4498-a75d-ba17210dbd3e",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 489,
- "position": {
- "x": 2050,
- "y": -75
- }
- },
- {
- "id": "9ae34718-a17d-401d-9859-086896c29fca",
- "type": "invocation",
- "data": {
- "id": "9ae34718-a17d-401d-9859-086896c29fca",
- "type": "face_off",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "0144d549-b04a-49c2-993f-9ecb2695191a",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "9c580dec-a958-43a4-9aa0-7ab9491c56a0",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "face_id": {
- "id": "1ee762e1-810f-46f7-a591-ecd28abff85b",
- "name": "face_id",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "minimum_confidence": {
- "id": "74d2bae8-7ac5-4f8f-afb7-1efc76ed72a0",
- "name": "minimum_confidence",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.5
- },
- "x_offset": {
- "id": "f1c1489d-cbbb-45d2-ba94-e150fc5ce24d",
- "name": "x_offset",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "y_offset": {
- "id": "6621377a-7e29-4841-aa47-aa7d438662f9",
- "name": "y_offset",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "padding": {
- "id": "0414c830-1be8-48cd-b0c8-6de10b099029",
- "name": "padding",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 64
- },
- "chunk": {
- "id": "22eba30a-c68a-4e5f-8f97-315830959b04",
- "name": "chunk",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "7d8fa807-0e9c-4d6a-a85a-388bd0cb5166",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "048361ca-314c-44a4-8b6e-ca4917e3468f",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "7b721071-63e5-4d54-9607-9fa2192163a8",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "mask": {
- "id": "a9c6194a-3dbd-4b18-af04-a85a9498f04e",
- "name": "mask",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "x": {
- "id": "d072582c-2db5-430b-9e50-b50277baa7d5",
- "name": "x",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "y": {
- "id": "40624813-e55d-42d7-bc14-dea48ccfd9c8",
- "name": "y",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 657,
- "position": {
- "x": 2575,
- "y": 200
- }
- },
- {
- "id": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "type": "invocation",
- "data": {
- "id": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "type": "img_resize",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "6bf91925-e22e-4bcb-90e4-f8ac32ddff36",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "65af3f4e-7a89-4df4-8ba9-377af1701d16",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "1f036fee-77f3-480f-b690-089b27616ab8",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "0c703b6e-e91a-4cd7-8b9e-b97865f76793",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "resample_mode": {
- "id": "ed77cf71-94cb-4da4-b536-75cb7aad8e54",
- "name": "resample_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "lanczos"
- }
- },
- "outputs": {
- "image": {
- "id": "0d9e437b-6a08-45e1-9a55-ef03ae28e616",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "7c56c704-b4e5-410f-bb62-cd441ddb454f",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "45e12e17-7f25-46bd-a804-4384ec6b98cc",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 3000,
- "y": 0
- }
- },
- {
- "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "invocation",
- "data": {
- "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "i2l",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "6c4d2827-4995-49d4-94ce-0ba0541d8839",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "vae": {
- "id": "9d6e3ab6-b6a4-45ac-ad75-0a96efba4c2f",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "9c258141-a75d-4ffd-bce5-f3fb3d90b720",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "2235cc48-53c9-4e8a-a74a-ed41c61f2993",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "latents": {
- "id": "8eb9293f-8f43-4c0c-b0fb-8c4db1200f87",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "ce493959-d308-423c-b0f5-db05912e0318",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "827bf290-94fb-455f-a970-f98ba8800eac",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 3100,
- "y": -275
- }
- },
- {
- "id": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "invocation",
- "data": {
- "id": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "673e4094-448b-4c59-ab05-d05b29b3e07f",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "3b3bac27-7e8a-4c4e-8b5b-c5231125302a",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "d7c20d11-fbfb-4613-ba58-bf00307e53be",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "e4af3bea-dae4-403f-8cec-6cb66fa888ce",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 80
- },
- "cfg_scale": {
- "id": "9ec125bb-6819-4970-89fe-fe09e6b96885",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 3
- },
- "denoising_start": {
- "id": "7190b40d-9467-4238-b180-0d19065258e2",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "Original Image Percent",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.2
- },
- "denoising_end": {
- "id": "c033f2d4-b60a-4a25-8a88-7852b556657a",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "bb043b8a-a119-4b2a-bb88-8afb364bdcc1",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "dpmpp_2m_sde_k"
- },
- "unet": {
- "id": "16d7688a-fc80-405c-aa55-a8ba2a1efecb",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "d0225ede-7d03-405d-b5b4-97c07d9b602b",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "6fbe0a17-6b85-4d3e-835d-0e23d3040bf4",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "2e4e1e6e-0278-47e3-a464-1a51760a9411",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "c9703a2e-4178-493c-90b9-81325a83a5ec",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "b2527e78-6f55-4274-aaa0-8fef1c928faa",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "cceec5a4-d3e9-4cff-a1e1-b5b62cb12588",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "6eca0515-4357-40be-ac8d-f84eb927dc31",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "14453d1c-6202-4377-811c-88ac9c024e77",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "0e0e04dc-4158-4461-b0ba-6c6af16e863c",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 705,
- "position": {
- "x": 4597.554345564559,
- "y": -265.6421598623905
- }
- },
- {
- "id": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "invocation",
- "data": {
- "id": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "noise",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "c6b5bc5e-ef09-4f9c-870e-1110a0f5017f",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 123451234
- },
- "width": {
- "id": "7bdd24b6-4f14-4d0a-b8fc-9b24145b4ba9",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "dc15bf97-b8d5-49c6-999b-798b33679418",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "use_cpu": {
- "id": "00626297-19dd-4989-9688-e8d527c9eacf",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "2915f8ae-0f6e-4f26-8541-0ebf477586b6",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "26587461-a24a-434d-9ae5-8d8f36fea221",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "335d08fc-8bf1-4393-8902-2c579f327b51",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4025,
- "y": -175
- }
- },
- {
- "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "type": "invocation",
- "data": {
- "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "2f077d6f-579e-4399-9986-3feabefa9ade",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "568a1537-dc7c-44f5-8a87-3571b0528b85",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "392c3757-ad3a-46af-8d76-9724ca30aad8",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "90f98601-c05d-453e-878b-18e23cc222b4",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "6be5cad7-dd41-4f83-98e7-124a6ad1728d",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "image": {
- "id": "6f90a4f5-42dd-472a-8f30-403bcbc16531",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "4d1c8a66-35fc-40e9-a7a9-d8604a247d33",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "e668cfb3-aedc-4032-94c9-b8add1fbaacf",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 4980.1395106966565,
- "y": -255.9158921745602
- }
- },
- {
- "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "invocation",
- "data": {
- "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "lscale",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "latents": {
- "id": "79e8f073-ddc3-416e-b818-6ef8ec73cc07",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "scale_factor": {
- "id": "23f78d24-72df-4bde-8d3c-8593ce507205",
- "name": "scale_factor",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1.5
- },
- "mode": {
- "id": "4ab30c38-57d3-480d-8b34-918887e92340",
- "name": "mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "bilinear"
- },
- "antialias": {
- "id": "22b39171-0003-44f0-9c04-d241581d2a39",
- "name": "antialias",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "latents": {
- "id": "f6d71aef-6251-4d51-afa8-f692a72bfd1f",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "8db4cf33-5489-4887-a5f6-5e926d959c40",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "74e1ec7c-50b6-4e97-a7b8-6602e6d78c08",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 3075,
- "y": -175
- }
- },
- {
- "id": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "invocation",
- "data": {
- "id": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "img_paste",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "159f5a3c-4b47-46dd-b8fe-450455ee3dcf",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "base_image": {
- "id": "445cfacf-5042-49ae-a63e-c19f21f90b1d",
- "name": "base_image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "image": {
- "id": "c292948b-8efd-4f5c-909d-d902cfd1509a",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "mask": {
- "id": "8b7bc7e9-35b5-45bc-9b8c-e15e92ab4d74",
- "name": "mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "x": {
- "id": "0694ba58-07bc-470b-80b5-9c7a99b64607",
- "name": "x",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "y": {
- "id": "40f8b20b-f804-4ec2-9622-285726f7665f",
- "name": "y",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop": {
- "id": "8a09a132-c07e-4cfb-8ec2-7093dc063f99",
- "name": "crop",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "4a96ec78-d4b6-435f-b5a3-6366cbfcfba7",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "c1ee69c7-6ca0-4604-abc9-b859f4178421",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "6c198681-5f16-4526-8e37-0bf000962acd",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 504,
- "position": {
- "x": 6000,
- "y": -200
- }
- },
- {
- "id": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "type": "invocation",
- "data": {
- "id": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "type": "img_resize",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "afbbb112-60e4-44c8-bdfd-30f48d58b236",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "2a348a03-07a3-4a97-93c8-46051045b32f",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "6b95969c-ca73-4b54-815d-7aae305a67bd",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "af83c526-c730-4a34-8f73-80f443fecc05",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "resample_mode": {
- "id": "43f7d6b5-ebe0-43c4-bf5d-8fb7fdc40a3f",
- "name": "resample_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "lanczos"
- }
- },
- "outputs": {
- "image": {
- "id": "e492b013-615d-4dfd-b0d8-7df7b5ba9a9d",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "05f829b3-c253-495a-b1ad-9906c0833e49",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "65d5d662-2527-4f3c-8a87-0a7d9d4486de",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 397,
- "position": {
- "x": 5500,
- "y": -225
- }
- },
- {
- "id": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05",
- "type": "invocation",
- "data": {
- "id": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05",
- "type": "float",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "d5d8063d-44f6-4e20-b557-2f4ce093c7ef",
- "name": "value",
- "fieldKind": "input",
- "label": "Orignal Image Percentage",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.4
- }
- },
- "outputs": {
- "value": {
- "id": "562416a4-0d75-48aa-835e-5e2d221dfbb7",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4025,
- "y": -75
- }
- },
- {
- "id": "64712037-92e8-483f-9f6e-87588539c1b8",
- "type": "invocation",
- "data": {
- "id": "64712037-92e8-483f-9f6e-87588539c1b8",
- "type": "float",
- "label": "CFG Main",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "750358d5-251d-4fe6-a673-2cde21995da2",
- "name": "value",
- "fieldKind": "input",
- "label": "CFG Main",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 6
- }
- },
- "outputs": {
- "value": {
- "id": "eea7f6d2-92e4-4581-b555-64a44fda2be9",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4025,
- "y": 75
- }
- },
- {
- "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9",
- "type": "invocation",
- "data": {
- "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9",
- "type": "rand_int",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "low": {
- "id": "31e29709-9f19-45b0-a2de-fdee29a50393",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "d47d875c-509d-4fa3-9112-e335d3144a17",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "15b8d1ea-d2ac-4b3a-9619-57bba9a6da75",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4025,
- "y": -275
- }
- },
- {
- "id": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "type": "invocation",
- "data": {
- "id": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "type": "main_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "54e737f9-2547-4bd9-a607-733d02f0c990",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MainModelField"
- },
- "value": {
- "model_name": "epicrealism_naturalSinRC1VAE",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "3483ea21-f0b3-4422-894b-36c5d7701365",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "dddd055f-5c1b-4e61-977b-6393da9006fa",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "879893b4-3415-4879-8dff-aa1727ef5e63",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 227,
- "position": {
- "x": 2050,
- "y": -525
- }
- },
- {
- "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "type": "invocation",
- "data": {
- "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "916b229a-38e1-45a2-a433-cca97495b143",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "ae9aeb1a-4ebd-4bc3-b6e6-a8c9adca01f6",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "4d59bad1-99a9-43e2-bdb4-7a0f3dd5b787",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 2550,
- "y": -525
- }
- },
- {
- "id": "22b750db-b85e-486b-b278-ac983e329813",
- "type": "invocation",
- "data": {
- "id": "22b750db-b85e-486b-b278-ac983e329813",
- "type": "ip_adapter",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "088a126c-ab1d-4c7a-879a-c1eea09eac8e",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "ip_adapter_model": {
- "id": "f2ac529f-f778-4a12-af12-0c7e449de17a",
- "name": "ip_adapter_model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IPAdapterModelField"
- },
- "value": {
- "model_name": "ip_adapter_plus_face_sd15",
- "base_model": "sd-1"
- }
- },
- "weight": {
- "id": "ddb4a7cb-607d-47e8-b46b-cc1be27ebde0",
- "name": "weight",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.5
- },
- "begin_step_percent": {
- "id": "1807371f-b56c-4777-baa2-de71e21f0b80",
- "name": "begin_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "end_step_percent": {
- "id": "4ea8e4dc-9cd7-445e-9b32-8934c652381b",
- "name": "end_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.8
- }
- },
- "outputs": {
- "ip_adapter": {
- "id": "739b5fed-d813-4611-8f87-1dded25a7619",
- "name": "ip_adapter",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IPAdapterField"
- }
- }
- }
- },
- "width": 320,
- "height": 396,
- "position": {
- "x": 3575,
- "y": -200
- }
- },
- {
- "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "type": "invocation",
- "data": {
- "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "type": "controlnet",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "96434c75-abd8-4b73-ab82-0b358e4735bf",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "control_model": {
- "id": "21551ac2-ee50-4fe8-b06c-5be00680fb5c",
- "name": "control_model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlNetModelField"
- },
- "value": {
- "model_name": "canny",
- "base_model": "sd-1"
- }
- },
- "control_weight": {
- "id": "1dacac0a-b985-4bdf-b4b5-b960f4cff6ed",
- "name": "control_weight",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 0.5
- },
- "begin_step_percent": {
- "id": "b2a3f128-7fc1-4c12-acc8-540f013c856b",
- "name": "begin_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "end_step_percent": {
- "id": "0e701834-f7ba-4a6e-b9cb-6d4aff5dacd8",
- "name": "end_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.5
- },
- "control_mode": {
- "id": "f9a5f038-ae80-4b6e-8a48-362a2c858299",
- "name": "control_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "balanced"
- },
- "resize_mode": {
- "id": "5369dd44-a708-4b66-8182-fea814d2a0ae",
- "name": "resize_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "just_resize"
- }
- },
- "outputs": {
- "control": {
- "id": "f470a1af-7b68-4849-a144-02bc345fd810",
- "name": "control",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlField"
- }
- }
- }
- },
- "width": 320,
- "height": 511,
- "position": {
- "x": 3950,
- "y": 150
- }
- },
- {
- "id": "8fe598c6-d447-44fa-a165-4975af77d080",
- "type": "invocation",
- "data": {
- "id": "8fe598c6-d447-44fa-a165-4975af77d080",
- "type": "canny_image_processor",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "855f4575-309a-4810-bd02-7c4a04e0efc8",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "7d2695fa-a617-431a-bc0e-69ac7c061651",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "low_threshold": {
- "id": "ceb37a49-c989-4afa-abbf-49b6e52ef663",
- "name": "low_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 100
- },
- "high_threshold": {
- "id": "6ec118f8-ca6c-4be7-9951-6eee58afbc1b",
- "name": "high_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 200
- }
- },
- "outputs": {
- "image": {
- "id": "264bcaaf-d185-43ce-9e1a-b6f707140c0c",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "0d374d8e-d5c7-49be-9057-443e32e45048",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "fa892b68-6135-4598-a765-e79cd5c6e3f6",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 3519.4131037388597,
- "y": 576.7946795840575
- }
- },
- {
- "id": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "type": "invocation",
- "data": {
- "id": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "type": "img_scale",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "46fc750e-7dda-4145-8f72-be88fb93f351",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "f7f41bb3-9a5a-4022-b671-0acc2819f7c3",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "scale_factor": {
- "id": "1ae95574-f725-4cbc-bb78-4c9db240f78a",
- "name": "scale_factor",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1.5
- },
- "resample_mode": {
- "id": "0756e37d-3d01-4c2c-9364-58e8978b04a2",
- "name": "resample_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "bicubic"
- }
- },
- "outputs": {
- "image": {
- "id": "2729e697-cacc-4874-94bf-3aee5c18b5f9",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "d9551981-cbd3-419a-bcb9-5b50600e8c18",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "3355c99e-cdc6-473b-a7c6-a9d1dcc941e5",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 3079.916484101321,
- "y": 151.0148192064986
- }
- },
- {
- "id": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc",
- "type": "invocation",
- "data": {
- "id": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc",
- "type": "mask_combine",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "061ea794-2494-429e-bfdd-5fbd2c7eeb99",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "mask1": {
- "id": "446c6c99-9cb5-4035-a452-ab586ef4ede9",
- "name": "mask1",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "mask2": {
- "id": "ebbe37b8-8bf3-4520-b6f5-631fe2d05d66",
- "name": "mask2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- }
- },
- "outputs": {
- "image": {
- "id": "7c33cfae-ea9a-4572-91ca-40fc4112369f",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "7410c10c-3060-44a9-b399-43555c3e1156",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "3eb74c96-ae3e-4091-b027-703428244fdf",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 283,
- "position": {
- "x": 5450,
- "y": 250
- }
- },
- {
- "id": "77da4e4d-5778-4469-8449-ffed03d54bdb",
- "type": "invocation",
- "data": {
- "id": "77da4e4d-5778-4469-8449-ffed03d54bdb",
- "type": "img_blur",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "755d4fa2-a520-4837-a3da-65e1f479e6e6",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "a4786d7d-9593-4f5f-830b-d94bb0e42bca",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "radius": {
- "id": "e1c9afa0-4d41-4664-b560-e1e85f467267",
- "name": "radius",
- "fieldKind": "input",
- "label": "Mask Blue",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 150
- },
- "blur_type": {
- "id": "f6231886-0981-444b-bd26-24674f87e7cb",
- "name": "blur_type",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "gaussian"
- }
- },
- "outputs": {
- "image": {
- "id": "20c7bcfc-269d-4119-8b45-6c59dd4005d7",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "37e228ef-cb59-4477-b18f-343609d7bb4e",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "2de6080e-8bc3-42cd-bb8a-afd72f082df4",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 5000,
- "y": 300
- }
- },
- {
- "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "type": "invocation",
- "data": {
- "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "type": "float",
- "label": "Face Detail Scale",
- "isOpen": false,
- "notes": "The image is cropped to the face and scaled to 512x512. This value can scale even more. Best result with value between 1-2.\n\n1 = 512\n2 = 1024\n\n",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "9b51a26f-af3c-4caa-940a-5183234b1ed7",
- "name": "value",
- "fieldKind": "input",
- "label": "Face Detail Scale",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1.5
- }
- },
- "outputs": {
- "value": {
- "id": "c7c87b77-c149-4e9c-8ed1-beb1ba013055",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 2550,
- "y": 125
- }
- },
- {
- "id": "a6482723-4e0a-4e40-98c0-b20622bf5f16",
- "type": "invocation",
- "data": {
- "id": "a6482723-4e0a-4e40-98c0-b20622bf5f16",
- "type": "face_mask_detection",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "inputs": {
- "metadata": {
- "id": "44e1fca6-3f06-4698-9562-6743c1f7a739",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "65ba4653-8c35-403d-838b-ddac075e4118",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "face_ids": {
- "id": "630e86e5-3041-47c4-8864-b8ee71ec7da5",
- "name": "face_ids",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "minimum_confidence": {
- "id": "8c4aef57-90c6-4904-9113-7189db1357c9",
- "name": "minimum_confidence",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.5
- },
- "x_offset": {
- "id": "2d409495-4aee-41e2-9688-f37fb6add537",
- "name": "x_offset",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "y_offset": {
- "id": "de3c1e68-6962-4520-b672-989afff1d2a1",
- "name": "y_offset",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "chunk": {
- "id": "9a11971a-0759-4782-902a-8300070d8861",
- "name": "chunk",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "invert_mask": {
- "id": "2abecba7-35d0-4cc4-9732-769e97d34c75",
- "name": "invert_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "af33585c-5451-4738-b1da-7ff83afdfbf8",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "52cec0bd-d2e5-4514-8ebf-1c0d8bd1b81d",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "05edbbc8-99df-42e0-9b9b-de7b555e44cc",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "mask": {
- "id": "cf928567-3e05-42dd-9591-0d3d942034f8",
- "name": "mask",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- }
- }
- },
- "width": 320,
- "height": 585,
- "position": {
- "x": 4300,
- "y": 600
- }
- }
- ],
- "edges": [
- {
- "id": "50a8db6a-3796-4522-8547-53275efa4e7d-de8b1a48-a2e4-42ca-90bb-66058bffd534-collapsed",
- "source": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "collapsed"
- },
- {
- "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31-2974e5b3-3d41-4b6f-9953-cd21e8f3a323-collapsed",
- "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "collapsed"
- },
- {
- "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534-2974e5b3-3d41-4b6f-9953-cd21e8f3a323-collapsed",
- "source": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "collapsed"
- },
- {
- "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323-35623411-ba3a-4eaa-91fd-1e0fda0a5b42-collapsed",
- "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "collapsed"
- },
- {
- "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9-35623411-ba3a-4eaa-91fd-1e0fda0a5b42-collapsed",
- "source": "c865f39f-f830-4ed7-88a5-e935cfe050a9",
- "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "collapsed"
- },
- {
- "id": "reactflow__edge-2c9bc2a6-6c03-4861-aad4-db884a7682f8image-9ae34718-a17d-401d-9859-086896c29fcaimage",
- "source": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "target": "9ae34718-a17d-401d-9859-086896c29fca",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaimage-50a8db6a-3796-4522-8547-53275efa4e7dimage",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-35623411-ba3a-4eaa-91fd-1e0fda0a5b42noise-bd06261d-a74a-4d1f-8374-745ed6194bc2noise",
- "source": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-de8b1a48-a2e4-42ca-90bb-66058bffd534latents-2974e5b3-3d41-4b6f-9953-cd21e8f3a323latents",
- "source": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323latents-bd06261d-a74a-4d1f-8374-745ed6194bc2latents",
- "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323width-35623411-ba3a-4eaa-91fd-1e0fda0a5b42width",
- "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "default",
- "sourceHandle": "width",
- "targetHandle": "width"
- },
- {
- "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323height-35623411-ba3a-4eaa-91fd-1e0fda0a5b42height",
- "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "default",
- "sourceHandle": "height",
- "targetHandle": "height"
- },
- {
- "id": "reactflow__edge-2c9bc2a6-6c03-4861-aad4-db884a7682f8image-a7d14545-aa09-4b96-bfc5-40c009af9110base_image",
- "source": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "target": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "base_image"
- },
- {
- "id": "reactflow__edge-2224ed72-2453-4252-bd89-3085240e0b6fimage-ff8c23dc-da7c-45b7-b5c9-d984b12f02efimage",
- "source": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcawidth-ff8c23dc-da7c-45b7-b5c9-d984b12f02efwidth",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "type": "default",
- "sourceHandle": "width",
- "targetHandle": "width"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaheight-ff8c23dc-da7c-45b7-b5c9-d984b12f02efheight",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "type": "default",
- "sourceHandle": "height",
- "targetHandle": "height"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcax-a7d14545-aa09-4b96-bfc5-40c009af9110x",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "default",
- "sourceHandle": "x",
- "targetHandle": "x"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcay-a7d14545-aa09-4b96-bfc5-40c009af9110y",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "default",
- "sourceHandle": "y",
- "targetHandle": "y"
- },
- {
- "id": "reactflow__edge-50a8db6a-3796-4522-8547-53275efa4e7dimage-de8b1a48-a2e4-42ca-90bb-66058bffd534image",
- "source": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05value-bd06261d-a74a-4d1f-8374-745ed6194bc2denoising_start",
- "source": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "denoising_start"
- },
- {
- "id": "reactflow__edge-64712037-92e8-483f-9f6e-87588539c1b8value-bd06261d-a74a-4d1f-8374-745ed6194bc2cfg_scale",
- "source": "64712037-92e8-483f-9f6e-87588539c1b8",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "cfg_scale"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcawidth-c59e815c-1f3a-4e2b-b6b8-66f4b005e955width",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "type": "default",
- "sourceHandle": "width",
- "targetHandle": "width"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaheight-c59e815c-1f3a-4e2b-b6b8-66f4b005e955height",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "type": "default",
- "sourceHandle": "height",
- "targetHandle": "height"
- },
- {
- "id": "reactflow__edge-ff8c23dc-da7c-45b7-b5c9-d984b12f02efimage-a7d14545-aa09-4b96-bfc5-40c009af9110image",
- "source": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef",
- "target": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-bd06261d-a74a-4d1f-8374-745ed6194bc2latents-2224ed72-2453-4252-bd89-3085240e0b6flatents",
- "source": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "target": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-c865f39f-f830-4ed7-88a5-e935cfe050a9value-35623411-ba3a-4eaa-91fd-1e0fda0a5b42seed",
- "source": "c865f39f-f830-4ed7-88a5-e935cfe050a9",
- "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- },
- {
- "id": "reactflow__edge-76ea1e31-eabe-4080-935e-e74ce20e2805clip-f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65clip",
- "source": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "target": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-76ea1e31-eabe-4080-935e-e74ce20e2805vae-de8b1a48-a2e4-42ca-90bb-66058bffd534vae",
- "source": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65conditioning-bd06261d-a74a-4d1f-8374-745ed6194bc2positive_conditioning",
- "source": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-44f2c190-eb03-460d-8d11-a94d13b33f19conditioning-bd06261d-a74a-4d1f-8374-745ed6194bc2negative_conditioning",
- "source": "44f2c190-eb03-460d-8d11-a94d13b33f19",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-76ea1e31-eabe-4080-935e-e74ce20e2805unet-bd06261d-a74a-4d1f-8374-745ed6194bc2unet",
- "source": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-76ea1e31-eabe-4080-935e-e74ce20e2805vae-2224ed72-2453-4252-bd89-3085240e0b6fvae",
- "source": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "target": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-76ea1e31-eabe-4080-935e-e74ce20e2805clip-44f2c190-eb03-460d-8d11-a94d13b33f19clip",
- "source": "76ea1e31-eabe-4080-935e-e74ce20e2805",
- "target": "44f2c190-eb03-460d-8d11-a94d13b33f19",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-22b750db-b85e-486b-b278-ac983e329813ip_adapter-bd06261d-a74a-4d1f-8374-745ed6194bc2ip_adapter",
- "source": "22b750db-b85e-486b-b278-ac983e329813",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "ip_adapter",
- "targetHandle": "ip_adapter"
- },
- {
- "id": "reactflow__edge-50a8db6a-3796-4522-8547-53275efa4e7dimage-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage",
- "source": "50a8db6a-3796-4522-8547-53275efa4e7d",
- "target": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage-22b750db-b85e-486b-b278-ac983e329813image",
- "source": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "target": "22b750db-b85e-486b-b278-ac983e329813",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-8fe598c6-d447-44fa-a165-4975af77d080image-f60b6161-8f26-42f6-89ff-545e6011e501image",
- "source": "8fe598c6-d447-44fa-a165-4975af77d080",
- "target": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage-8fe598c6-d447-44fa-a165-4975af77d080image",
- "source": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "target": "8fe598c6-d447-44fa-a165-4975af77d080",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-f60b6161-8f26-42f6-89ff-545e6011e501control-bd06261d-a74a-4d1f-8374-745ed6194bc2control",
- "source": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2",
- "type": "default",
- "sourceHandle": "control",
- "targetHandle": "control"
- },
- {
- "id": "reactflow__edge-c59e815c-1f3a-4e2b-b6b8-66f4b005e955image-381d5b6a-f044-48b0-bc07-6138fbfa8dfcmask2",
- "source": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "target": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "mask2"
- },
- {
- "id": "reactflow__edge-381d5b6a-f044-48b0-bc07-6138fbfa8dfcimage-a7d14545-aa09-4b96-bfc5-40c009af9110mask",
- "source": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc",
- "target": "a7d14545-aa09-4b96-bfc5-40c009af9110",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "mask"
- },
- {
- "id": "reactflow__edge-77da4e4d-5778-4469-8449-ffed03d54bdbimage-381d5b6a-f044-48b0-bc07-6138fbfa8dfcmask1",
- "source": "77da4e4d-5778-4469-8449-ffed03d54bdb",
- "target": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "mask1"
- },
- {
- "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcamask-77da4e4d-5778-4469-8449-ffed03d54bdbimage",
- "source": "9ae34718-a17d-401d-9859-086896c29fca",
- "target": "77da4e4d-5778-4469-8449-ffed03d54bdb",
- "type": "default",
- "sourceHandle": "mask",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-f0de6c44-4515-4f79-bcc0-dee111bcfe31value-2974e5b3-3d41-4b6f-9953-cd21e8f3a323scale_factor",
- "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "scale_factor"
- },
- {
- "id": "reactflow__edge-f0de6c44-4515-4f79-bcc0-dee111bcfe31value-4bd4ae80-567f-4366-b8c6-3bb06f4fb46ascale_factor",
- "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31",
- "target": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "scale_factor"
- },
- {
- "id": "reactflow__edge-2c9bc2a6-6c03-4861-aad4-db884a7682f8image-a6482723-4e0a-4e40-98c0-b20622bf5f16image",
- "source": "2c9bc2a6-6c03-4861-aad4-db884a7682f8",
- "target": "a6482723-4e0a-4e40-98c0-b20622bf5f16",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-a6482723-4e0a-4e40-98c0-b20622bf5f16image-c59e815c-1f3a-4e2b-b6b8-66f4b005e955image",
- "source": "a6482723-4e0a-4e40-98c0-b20622bf5f16",
- "target": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/Multi_ControlNet_Canny_and_Depth.json b/docs/workflows/Multi_ControlNet_Canny_and_Depth.json
deleted file mode 100644
index 70dc2047725..00000000000
--- a/docs/workflows/Multi_ControlNet_Canny_and_Depth.json
+++ /dev/null
@@ -1,1480 +0,0 @@
-{
- "id": "1e385b84-86f8-452e-9697-9e5abed20518",
- "name": "Multi ControlNet (Canny & Depth)",
- "author": "InvokeAI",
- "description": "A sample workflow using canny & depth ControlNets to guide the generation process. ",
- "version": "1.0.0",
- "contact": "invoke@invoke.ai",
- "tags": "ControlNet, canny, depth",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "fieldName": "model"
- },
- {
- "nodeId": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "fieldName": "prompt"
- },
- {
- "nodeId": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "fieldName": "prompt"
- },
- {
- "nodeId": "c4b23e64-7986-40c4-9cad-46327b12e204",
- "fieldName": "image"
- },
- {
- "nodeId": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
- "fieldName": "image"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
- "type": "invocation",
- "data": {
- "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
- "type": "image",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "189c8adf-68cc-4774-a729-49da89f6fdf1",
- "name": "image",
- "fieldKind": "input",
- "label": "Depth Input Image",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- }
- },
- "outputs": {
- "image": {
- "id": "1a31cacd-9d19-4f32-b558-c5e4aa39ce73",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "12f298fd-1d11-4cca-9426-01240f7ec7cf",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "c47dabcb-44e8-40c9-992d-81dca59f598e",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 3625,
- "y": -75
- }
- },
- {
- "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
- "type": "invocation",
- "data": {
- "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
- "type": "controlnet",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "4e0a3172-d3c2-4005-a84c-fa12a404f8a0",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "control_model": {
- "id": "8cb2d998-4086-430a-8b13-94cbc81e3ca3",
- "name": "control_model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlNetModelField"
- },
- "value": {
- "model_name": "depth",
- "base_model": "sd-1"
- }
- },
- "control_weight": {
- "id": "5e32bd8a-9dc8-42d8-9bcc-c2b0460c0b0f",
- "name": "control_weight",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 1
- },
- "begin_step_percent": {
- "id": "c258a276-352a-416c-8358-152f11005c0c",
- "name": "begin_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "end_step_percent": {
- "id": "43001125-0d70-4f87-8e79-da6603ad6c33",
- "name": "end_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "control_mode": {
- "id": "d2f14561-9443-4374-9270-e2f05007944e",
- "name": "control_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "balanced"
- },
- "resize_mode": {
- "id": "727ee7d3-8bf6-4c7d-8b8a-43546b3b59cd",
- "name": "resize_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "just_resize"
- }
- },
- "outputs": {
- "control": {
- "id": "b034aa0f-4d0d-46e4-b5e3-e25a9588d087",
- "name": "control",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlField"
- }
- }
- }
- },
- "width": 320,
- "height": 511,
- "position": {
- "x": 4477.604342844504,
- "y": -49.39005411272677
- }
- },
- {
- "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "type": "invocation",
- "data": {
- "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "7c2c4771-2161-4d77-aced-ff8c4b3f1c15",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "06d59e91-9cca-411d-bf05-86b099b3e8f7",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "858bc33c-134c-4bf6-8855-f943e1d26f14",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 4075,
- "y": -825
- }
- },
- {
- "id": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "type": "invocation",
- "data": {
- "id": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "type": "main_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "f4a915a5-593e-4b6d-9198-c78eb5cefaed",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MainModelField"
- },
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "ee24fb16-da38-4c66-9fbc-e8f296ed40d2",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "f3fb0524-8803-41c1-86db-a61a13ee6a33",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "5c4878a8-b40f-44ab-b146-1c1f42c860b3",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 227,
- "position": {
- "x": 3600,
- "y": -1000
- }
- },
- {
- "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "type": "invocation",
- "data": {
- "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "type": "compel",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "7c2c4771-2161-4d77-aced-ff8c4b3f1c15",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "06d59e91-9cca-411d-bf05-86b099b3e8f7",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "858bc33c-134c-4bf6-8855-f943e1d26f14",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 256,
- "position": {
- "x": 4075,
- "y": -1125
- }
- },
- {
- "id": "d204d184-f209-4fae-a0a1-d152800844e1",
- "type": "invocation",
- "data": {
- "id": "d204d184-f209-4fae-a0a1-d152800844e1",
- "type": "controlnet",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "4e0a3172-d3c2-4005-a84c-fa12a404f8a0",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "control_model": {
- "id": "8cb2d998-4086-430a-8b13-94cbc81e3ca3",
- "name": "control_model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlNetModelField"
- },
- "value": {
- "model_name": "canny",
- "base_model": "sd-1"
- }
- },
- "control_weight": {
- "id": "5e32bd8a-9dc8-42d8-9bcc-c2b0460c0b0f",
- "name": "control_weight",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 1
- },
- "begin_step_percent": {
- "id": "c258a276-352a-416c-8358-152f11005c0c",
- "name": "begin_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "end_step_percent": {
- "id": "43001125-0d70-4f87-8e79-da6603ad6c33",
- "name": "end_step_percent",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "control_mode": {
- "id": "d2f14561-9443-4374-9270-e2f05007944e",
- "name": "control_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "balanced"
- },
- "resize_mode": {
- "id": "727ee7d3-8bf6-4c7d-8b8a-43546b3b59cd",
- "name": "resize_mode",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "EnumField"
- },
- "value": "just_resize"
- }
- },
- "outputs": {
- "control": {
- "id": "b034aa0f-4d0d-46e4-b5e3-e25a9588d087",
- "name": "control",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ControlField"
- }
- }
- }
- },
- "width": 320,
- "height": 511,
- "position": {
- "x": 4479.68542130465,
- "y": -618.4221638099414
- }
- },
- {
- "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
- "type": "invocation",
- "data": {
- "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
- "type": "image",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "image": {
- "id": "189c8adf-68cc-4774-a729-49da89f6fdf1",
- "name": "image",
- "fieldKind": "input",
- "label": "Canny Input Image",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- }
- },
- "outputs": {
- "image": {
- "id": "1a31cacd-9d19-4f32-b558-c5e4aa39ce73",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "12f298fd-1d11-4cca-9426-01240f7ec7cf",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "c47dabcb-44e8-40c9-992d-81dca59f598e",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 3625,
- "y": -425
- }
- },
- {
- "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "type": "invocation",
- "data": {
- "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "type": "collect",
- "label": "ControlNet Collection",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "item": {
- "id": "b16ae602-8708-4b1b-8d4f-9e0808d429ab",
- "name": "item",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "CollectionItemField"
- }
- }
- },
- "outputs": {
- "collection": {
- "id": "d8987dd8-dec8-4d94-816a-3e356af29884",
- "name": "collection",
- "fieldKind": "output",
- "type": {
- "isCollection": true,
- "isCollectionOrScalar": false,
- "name": "CollectionField"
- }
- }
- }
- },
- "width": 320,
- "height": 104,
- "position": {
- "x": 4875,
- "y": -575
- }
- },
- {
- "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
- "type": "invocation",
- "data": {
- "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
- "type": "midas_depth_image_processor",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "77f91980-c696-4a18-a9ea-6e2fc329a747",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "50710a20-2af5-424d-9d17-aa08167829c6",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "a_mult": {
- "id": "f3b26f9d-2498-415e-9c01-197a8d06c0a5",
- "name": "a_mult",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 2
- },
- "bg_th": {
- "id": "4b1eb3ae-9d4a-47d6-b0ed-da62501e007f",
- "name": "bg_th",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.1
- }
- },
- "outputs": {
- "image": {
- "id": "b4ed637c-c4a0-4fdd-a24e-36d6412e4ccf",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "6bf9b609-d72c-4239-99bd-390a73cc3a9c",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "3e8aef09-cf44-4e3e-a490-d3c9e7b23119",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 4100,
- "y": -75
- }
- },
- {
- "id": "c826ba5e-9676-4475-b260-07b85e88753c",
- "type": "invocation",
- "data": {
- "id": "c826ba5e-9676-4475-b260-07b85e88753c",
- "type": "canny_image_processor",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "08331ea6-99df-4e61-a919-204d9bfa8fb2",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "image": {
- "id": "33a37284-06ac-459c-ba93-1655e4f69b2d",
- "name": "image",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "low_threshold": {
- "id": "21ec18a3-50c5-4ba1-9642-f921744d594f",
- "name": "low_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 100
- },
- "high_threshold": {
- "id": "ebeab271-a5ff-4c88-acfd-1d0271ab6ed4",
- "name": "high_threshold",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 200
- }
- },
- "outputs": {
- "image": {
- "id": "c0caadbf-883f-4cb4-a62d-626b9c81fc4e",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "df225843-8098-49c0-99d1-3b0b6600559f",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "e4abe0de-aa16-41f3-9cd7-968b49db5da3",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 340,
- "position": {
- "x": 4095.757337055795,
- "y": -455.63440891935863
- }
- },
- {
- "id": "9db25398-c869-4a63-8815-c6559341ef12",
- "type": "invocation",
- "data": {
- "id": "9db25398-c869-4a63-8815-c6559341ef12",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "2f269793-72e5-4ff3-b76c-fab4f93e983f",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "4aaedd3b-cc77-420c-806e-c7fa74ec4cdf",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "432b066a-2462-4d18-83d9-64620b72df45",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "61f86e0f-7c46-40f8-b3f5-fe2f693595ca",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "39b6c89a-37ef-4a7e-9509-daeca49d5092",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "6204e9b0-61dd-4250-b685-2092ba0e28e6",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "b4140649-8d5d-4d2d-bfa6-09e389ede5f9",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "f3a0c0c8-fc24-4646-8be1-ed8cdd140828",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 5675,
- "y": -825
- }
- },
- {
- "id": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "invocation",
- "data": {
- "id": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "869cd309-c238-444b-a1a0-5021f99785ba",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "343447b4-1e37-4e9e-8ac7-4d04864066af",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "b556571e-0cf9-4e03-8cfc-5caad937d957",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "a3b3d2de-9308-423e-b00d-c209c3e6e808",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 10
- },
- "cfg_scale": {
- "id": "b13c50a4-ec7e-4579-b0ef-2fe5df2605ea",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "57d5d755-f58f-4347-b991-f0bca4a0ab29",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "323e78a6-880a-4d73-a62c-70faff965aa6",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "c25fdc17-a089-43ac-953e-067c45d5c76b",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- },
- "unet": {
- "id": "6cde662b-e633-4569-b6b4-ec87c52c9c11",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "276a4df9-bb26-4505-a4d3-a94e18c7b541",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "48d40c51-b5e2-4457-a428-eef0696695e8",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "75dd8af2-e7d7-48b4-a574-edd9f6e686ad",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "b90460cf-d0c9-4676-8909-2e8e22dc8ee5",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "9223d67b-1dd7-4b34-a45f-ed0a725d9702",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "4ee99177-6923-4b7f-8fe0-d721dd7cb05b",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "7fb4e326-a974-43e8-9ee7-2e3ab235819d",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "6bb8acd0-8973-4195-a095-e376385dc705",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "795dea52-1c7d-4e64-99f7-2f60ec6e3ab9",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 705,
- "position": {
- "x": 5274.672987098195,
- "y": -823.0752416664332
- }
- },
- {
- "id": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b",
- "type": "invocation",
- "data": {
- "id": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b",
- "type": "noise",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "inputs": {
- "seed": {
- "id": "96d7667a-9c56-4fb4-99db-868e2f08e874",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "1ce644ea-c9bf-48c5-9822-bdec0d2895c5",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "26d68b53-8a04-4db7-b0f8-57c9bddc0e49",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "use_cpu": {
- "id": "cf8fb92e-2a8e-4cd5-baf5-4011e0ddfa22",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "d9cb9305-6b3a-49a9-b27c-00fb3a58b85c",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "4ff28d00-ceee-42b8-90e7-f5e5a518376d",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "a6314b9c-346a-4aa6-9260-626ed46c060a",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4875,
- "y": -675
- }
- },
- {
- "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce",
- "type": "invocation",
- "data": {
- "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce",
- "type": "rand_int",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "inputs": {
- "low": {
- "id": "a190ad12-a6bd-499b-a82a-100e09fe9aa4",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "a085063f-b9ba-46f2-a21b-c46c321949aa",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "a15aff56-4874-47fe-be32-d66745ed2ab5",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 4875,
- "y": -750
- }
- }
- ],
- "edges": [
- {
- "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce-2e77a0a1-db6a-47a2-a8bf-1e003be6423b-collapsed",
- "source": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce",
- "target": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b",
- "type": "collapsed"
- },
- {
- "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9clip-7ce68934-3419-42d4-ac70-82cfc9397306clip",
- "source": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "target": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9clip-273e3f96-49ea-4dc5-9d5b-9660390f14e1clip",
- "source": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "target": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-a33199c2-8340-401e-b8a2-42ffa875fc1ccontrol-ca4d5059-8bfb-447f-b415-da0faba5a143item",
- "source": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
- "target": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "type": "default",
- "sourceHandle": "control",
- "targetHandle": "item"
- },
- {
- "id": "reactflow__edge-d204d184-f209-4fae-a0a1-d152800844e1control-ca4d5059-8bfb-447f-b415-da0faba5a143item",
- "source": "d204d184-f209-4fae-a0a1-d152800844e1",
- "target": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "type": "default",
- "sourceHandle": "control",
- "targetHandle": "item"
- },
- {
- "id": "reactflow__edge-8e860e51-5045-456e-bf04-9a62a2a5c49eimage-018b1214-c2af-43a7-9910-fb687c6726d7image",
- "source": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
- "target": "018b1214-c2af-43a7-9910-fb687c6726d7",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-018b1214-c2af-43a7-9910-fb687c6726d7image-a33199c2-8340-401e-b8a2-42ffa875fc1cimage",
- "source": "018b1214-c2af-43a7-9910-fb687c6726d7",
- "target": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-c4b23e64-7986-40c4-9cad-46327b12e204image-c826ba5e-9676-4475-b260-07b85e88753cimage",
- "source": "c4b23e64-7986-40c4-9cad-46327b12e204",
- "target": "c826ba5e-9676-4475-b260-07b85e88753c",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-c826ba5e-9676-4475-b260-07b85e88753cimage-d204d184-f209-4fae-a0a1-d152800844e1image",
- "source": "c826ba5e-9676-4475-b260-07b85e88753c",
- "target": "d204d184-f209-4fae-a0a1-d152800844e1",
- "type": "default",
- "sourceHandle": "image",
- "targetHandle": "image"
- },
- {
- "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9vae-9db25398-c869-4a63-8815-c6559341ef12vae",
- "source": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "target": "9db25398-c869-4a63-8815-c6559341ef12",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-ac481b7f-08bf-4a9d-9e0c-3a82ea5243celatents-9db25398-c869-4a63-8815-c6559341ef12latents",
- "source": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "target": "9db25398-c869-4a63-8815-c6559341ef12",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-ca4d5059-8bfb-447f-b415-da0faba5a143collection-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cecontrol",
- "source": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "default",
- "sourceHandle": "collection",
- "targetHandle": "control"
- },
- {
- "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9unet-ac481b7f-08bf-4a9d-9e0c-3a82ea5243ceunet",
- "source": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-273e3f96-49ea-4dc5-9d5b-9660390f14e1conditioning-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cenegative_conditioning",
- "source": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-7ce68934-3419-42d4-ac70-82cfc9397306conditioning-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cepositive_conditioning",
- "source": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-2e77a0a1-db6a-47a2-a8bf-1e003be6423bnoise-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cenoise",
- "source": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b",
- "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-8b260b4d-3fd6-44d4-b1be-9f0e43c628cevalue-2e77a0a1-db6a-47a2-a8bf-1e003be6423bseed",
- "source": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce",
- "target": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/Prompt_from_File.json b/docs/workflows/Prompt_from_File.json
deleted file mode 100644
index 08e76fd7932..00000000000
--- a/docs/workflows/Prompt_from_File.json
+++ /dev/null
@@ -1,975 +0,0 @@
-{
- "name": "Prompt from File",
- "author": "InvokeAI",
- "description": "Sample workflow using Prompt from File node",
- "version": "0.1.0",
- "contact": "invoke@invoke.ai",
- "tags": "text2image, prompt from file, default",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "fieldName": "model"
- },
- {
- "nodeId": "1b7e0df8-8589-4915-a4ea-c0088f15d642",
- "fieldName": "file_path"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "id": "d1609af5-eb0a-4f73-b573-c9af96a8d6bf",
- "nodes": [
- {
- "id": "c2eaf1ba-5708-4679-9e15-945b8b432692",
- "type": "invocation",
- "data": {
- "id": "c2eaf1ba-5708-4679-9e15-945b8b432692",
- "type": "compel",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "dcdf3f6d-9b96-4bcd-9b8d-f992fefe4f62",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "3f1981c9-d8a9-42eb-a739-4f120eb80745",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "46205e6c-c5e2-44cb-9c82-1cd20b95674a",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 925,
- "y": -200
- }
- },
- {
- "id": "1b7e0df8-8589-4915-a4ea-c0088f15d642",
- "type": "invocation",
- "data": {
- "id": "1b7e0df8-8589-4915-a4ea-c0088f15d642",
- "type": "prompt_from_file",
- "label": "Prompts from File",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "file_path": {
- "id": "37e37684-4f30-4ec8-beae-b333e550f904",
- "name": "file_path",
- "fieldKind": "input",
- "label": "Prompts File Path",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "pre_prompt": {
- "id": "7de02feb-819a-4992-bad3-72a30920ddea",
- "name": "pre_prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "post_prompt": {
- "id": "95f191d8-a282-428e-bd65-de8cb9b7513a",
- "name": "post_prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "start_line": {
- "id": "efee9a48-05ab-4829-8429-becfa64a0782",
- "name": "start_line",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1
- },
- "max_prompts": {
- "id": "abebb428-3d3d-49fd-a482-4e96a16fff08",
- "name": "max_prompts",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1
- }
- },
- "outputs": {
- "collection": {
- "id": "77d5d7f1-9877-4ab1-9a8c-33e9ffa9abf3",
- "name": "collection",
- "fieldKind": "output",
- "type": {
- "isCollection": true,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "width": 320,
- "height": 580,
- "position": {
- "x": 475,
- "y": -400
- }
- },
- {
- "id": "1b89067c-3f6b-42c8-991f-e3055789b251",
- "type": "invocation",
- "data": {
- "id": "1b89067c-3f6b-42c8-991f-e3055789b251",
- "type": "iterate",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.1.0",
- "inputs": {
- "collection": {
- "id": "4c564bf8-5ed6-441e-ad2c-dda265d5785f",
- "name": "collection",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": true,
- "isCollectionOrScalar": false,
- "name": "CollectionField"
- }
- }
- },
- "outputs": {
- "item": {
- "id": "36340f9a-e7a5-4afa-b4b5-313f4e292380",
- "name": "item",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "CollectionItemField"
- }
- },
- "index": {
- "id": "1beca95a-2159-460f-97ff-c8bab7d89336",
- "name": "index",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "total": {
- "id": "ead597b8-108e-4eda-88a8-5c29fa2f8df9",
- "name": "total",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 925,
- "y": -400
- }
- },
- {
- "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "type": "invocation",
- "data": {
- "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "type": "main_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "3f264259-3418-47d5-b90d-b6600e36ae46",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MainModelField"
- },
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "8e182ea2-9d0a-4c02-9407-27819288d4b5",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "d67d9d30-058c-46d5-bded-3d09d6d1aa39",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "89641601-0429-4448-98d5-190822d920d8",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 227,
- "position": {
- "x": 0,
- "y": -375
- }
- },
- {
- "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "invocation",
- "data": {
- "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "compel",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "dcdf3f6d-9b96-4bcd-9b8d-f992fefe4f62",
- "name": "prompt",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "3f1981c9-d8a9-42eb-a739-4f120eb80745",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "46205e6c-c5e2-44cb-9c82-1cd20b95674a",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 925,
- "y": -275
- }
- },
- {
- "id": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
- "type": "invocation",
- "data": {
- "id": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
- "type": "noise",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "b722d84a-eeee-484f-bef2-0250c027cb67",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "d5f8ce11-0502-4bfc-9a30-5757dddf1f94",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "f187d5ff-38a5-4c3f-b780-fc5801ef34af",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "use_cpu": {
- "id": "12f112b8-8b76-4816-b79e-662edc9f9aa5",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "08576ad1-96d9-42d2-96ef-6f5c1961933f",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "f3e1f94a-258d-41ff-9789-bd999bd9f40d",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "6cefc357-4339-415e-a951-49b9c2be32f4",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 925,
- "y": 25
- }
- },
- {
- "id": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5",
- "type": "invocation",
- "data": {
- "id": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5",
- "type": "rand_int",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "low": {
- "id": "b9fc6cf1-469c-4037-9bf0-04836965826f",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "06eac725-0f60-4ba2-b8cd-7ad9f757488c",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "df08c84e-7346-4e92-9042-9e5cb773aaff",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 925,
- "y": -50
- }
- },
- {
- "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "type": "invocation",
- "data": {
- "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "022e4b33-562b-438d-b7df-41c3fd931f40",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "67cb6c77-a394-4a66-a6a9-a0a7dcca69ec",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "7b3fd9ad-a4ef-4e04-89fa-3832a9902dbd",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "5ac5680d-3add-4115-8ec0-9ef5bb87493b",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "db8297f5-55f8-452f-98cf-6572c2582152",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "d8778d0c-592a-4960-9280-4e77e00a7f33",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "c8b0a75a-f5de-4ff2-9227-f25bb2b97bec",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "83c05fbf-76b9-49ab-93c4-fa4b10e793e4",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 2037.861329274915,
- "y": -329.8393457509562
- }
- },
- {
- "id": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "invocation",
- "data": {
- "id": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "751fb35b-3f23-45ce-af1c-053e74251337",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "b9dc06b6-7481-4db1-a8c2-39d22a5eacff",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "6e15e439-3390-48a4-8031-01e0e19f0e1d",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "bfdfb3df-760b-4d51-b17b-0abb38b976c2",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 10
- },
- "cfg_scale": {
- "id": "47770858-322e-41af-8494-d8b63ed735f3",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "2ba78720-ee02-4130-a348-7bc3531f790b",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "a874dffb-d433-4d1a-9f59-af4367bb05e4",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "36e021ad-b762-4fe4-ad4d-17f0291c40b2",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- },
- "unet": {
- "id": "98d3282d-f9f6-4b5e-b9e8-58658f1cac78",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "f2ea3216-43d5-42b4-887f-36e8f7166d53",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "d0780610-a298-47c8-a54e-70e769e0dfe2",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "fdb40970-185e-4ea8-8bb5-88f06f91f46a",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "3af2d8c5-de83-425c-a100-49cb0f1f4385",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "e05b538a-1b5a-4aa5-84b1-fd2361289a81",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "463a419e-df30-4382-8ffb-b25b25abe425",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "559ee688-66cf-4139-8b82-3d3aa69995ce",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "0b4285c2-e8b9-48e5-98f6-0a49d3f98fd2",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "8b0881b9-45e5-47d5-b526-24b6661de0ee",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 705,
- "position": {
- "x": 1570.9941088179146,
- "y": -407.6505491604564
- }
- }
- ],
- "edges": [
- {
- "id": "1b89067c-3f6b-42c8-991f-e3055789b251-fc9d0e35-a6de-4a19-84e1-c72497c823f6-collapsed",
- "source": "1b89067c-3f6b-42c8-991f-e3055789b251",
- "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "collapsed"
- },
- {
- "id": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5-0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77-collapsed",
- "source": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5",
- "target": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
- "type": "collapsed"
- },
- {
- "id": "reactflow__edge-1b7e0df8-8589-4915-a4ea-c0088f15d642collection-1b89067c-3f6b-42c8-991f-e3055789b251collection",
- "source": "1b7e0df8-8589-4915-a4ea-c0088f15d642",
- "target": "1b89067c-3f6b-42c8-991f-e3055789b251",
- "type": "default",
- "sourceHandle": "collection",
- "targetHandle": "collection"
- },
- {
- "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426clip-fc9d0e35-a6de-4a19-84e1-c72497c823f6clip",
- "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-1b89067c-3f6b-42c8-991f-e3055789b251item-fc9d0e35-a6de-4a19-84e1-c72497c823f6prompt",
- "source": "1b89067c-3f6b-42c8-991f-e3055789b251",
- "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "default",
- "sourceHandle": "item",
- "targetHandle": "prompt"
- },
- {
- "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426clip-c2eaf1ba-5708-4679-9e15-945b8b432692clip",
- "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "target": "c2eaf1ba-5708-4679-9e15-945b8b432692",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5value-0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77seed",
- "source": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5",
- "target": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- },
- {
- "id": "reactflow__edge-fc9d0e35-a6de-4a19-84e1-c72497c823f6conditioning-2fb1577f-0a56-4f12-8711-8afcaaaf1d5epositive_conditioning",
- "source": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-c2eaf1ba-5708-4679-9e15-945b8b432692conditioning-2fb1577f-0a56-4f12-8711-8afcaaaf1d5enegative_conditioning",
- "source": "c2eaf1ba-5708-4679-9e15-945b8b432692",
- "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77noise-2fb1577f-0a56-4f12-8711-8afcaaaf1d5enoise",
- "source": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
- "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426unet-2fb1577f-0a56-4f12-8711-8afcaaaf1d5eunet",
- "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-2fb1577f-0a56-4f12-8711-8afcaaaf1d5elatents-491ec988-3c77-4c37-af8a-39a0c4e7a2a1latents",
- "source": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
- "target": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426vae-491ec988-3c77-4c37-af8a-39a0c4e7a2a1vae",
- "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "target": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/QR_Code_Monster.json b/docs/workflows/QR_Code_Monster.json
deleted file mode 100644
index f0ae5d74fd4..00000000000
--- a/docs/workflows/QR_Code_Monster.json
+++ /dev/null
@@ -1,758 +0,0 @@
-{
- "name": "QR Code Monster",
- "author": "InvokeAI",
- "description": "Sample workflow for create images with QR code Monster ControlNet",
- "version": "1.0.1",
- "contact": "invoke@invoke.ai",
- "tags": "qrcode, controlnet, default",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "a6cc0986-f928-4a7e-8d44-ba2d4b36f54a",
- "fieldName": "image"
- },
- {
- "nodeId": "aca3b054-bfba-4392-bd20-6476f59504df",
- "fieldName": "prompt"
- },
- {
- "nodeId": "3db7cee0-31e2-4a3d-94a1-268cb16177dd",
- "fieldName": "prompt"
- }
- ],
- "meta": {
- "version": "1.0.0"
- },
- "nodes": [
- {
- "id": "3db7cee0-31e2-4a3d-94a1-268cb16177dd",
- "type": "invocation",
- "data": {
- "id": "3db7cee0-31e2-4a3d-94a1-268cb16177dd",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "6a1fe244-5656-4f8c-91d1-1fb474e28807",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "value": ""
- },
- "clip": {
- "id": "f24688f3-29b8-4a2d-8603-046e5a5c7250",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "700528eb-3f8b-4745-b540-34f919b5b228",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "Prompt",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 261,
- "position": {
- "x": 773.0502679628016,
- "y": 1622.4836086770556
- }
- },
- {
- "id": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "type": "invocation",
- "data": {
- "id": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "id": "cb36b6d3-6c1f-4911-a200-646745b0ff74",
- "name": "model",
- "type": "MainModelField",
- "fieldKind": "input",
- "label": "",
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "7246895b-b252-49bc-b952-8d801b4672f7",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "output"
- },
- "clip": {
- "id": "3c2aedb8-30d5-4d4b-99df-d06a0d7bedc6",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "output"
- },
- "vae": {
- "id": "b9743815-5501-4bbb-8bde-8bd6ba298a4e",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 226,
- "position": {
- "x": 211.58866462619744,
- "y": 1376.0542388105248
- }
- },
- {
- "id": "aca3b054-bfba-4392-bd20-6476f59504df",
- "type": "invocation",
- "data": {
- "id": "aca3b054-bfba-4392-bd20-6476f59504df",
- "type": "compel",
- "inputs": {
- "prompt": {
- "id": "6a1fe244-5656-4f8c-91d1-1fb474e28807",
- "name": "prompt",
- "type": "string",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "value": ""
- },
- "clip": {
- "id": "f24688f3-29b8-4a2d-8603-046e5a5c7250",
- "name": "clip",
- "type": "ClipField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "conditioning": {
- "id": "700528eb-3f8b-4745-b540-34f919b5b228",
- "name": "conditioning",
- "type": "ConditioningField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 261,
- "position": {
- "x": 770.6491131680111,
- "y": 1316.379247112241
- }
- },
- {
- "id": "a6cc0986-f928-4a7e-8d44-ba2d4b36f54a",
- "type": "invocation",
- "data": {
- "id": "a6cc0986-f928-4a7e-8d44-ba2d4b36f54a",
- "type": "image",
- "inputs": {
- "image": {
- "id": "89ba5d58-28c9-4e04-a5df-79fb7a6f3531",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": "QR Code / Hidden Image"
- }
- },
- "outputs": {
- "image": {
- "id": "54335653-0e17-42da-b9e8-83c5fb5af670",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "a3c65953-39ea-4d97-8858-d65154ff9d11",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "2c7db511-ebc9-4286-a46b-bc11e0fd779f",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 225,
- "position": {
- "x": 700.5034176864369,
- "y": 1981.749600549388
- }
- },
- {
- "id": "280fd8a7-3b0c-49fe-8be4-6246e08b6c9a",
- "type": "invocation",
- "data": {
- "id": "280fd8a7-3b0c-49fe-8be4-6246e08b6c9a",
- "type": "noise",
- "inputs": {
- "seed": {
- "id": "7c6c76dd-127b-4829-b1ec-430790cb7ed7",
- "name": "seed",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "width": {
- "id": "8ec6a525-a421-40d8-a17e-39e7b6836438",
- "name": "width",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "height": {
- "id": "6af1e58a-e2ee-4ec4-9f06-d8d0412922ca",
- "name": "height",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 512
- },
- "use_cpu": {
- "id": "26662e99-5720-43a6-a5d8-06c9dab0e261",
- "name": "use_cpu",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "cb4c4dfc-a744-49eb-af4f-677448e28407",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "97e87be6-e81f-40a3-a522-28ebe4aad0ac",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "80784420-f1e1-47b0-bd1d-1d381a15e22d",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 1182.460291960481,
- "y": 1759.592972960265
- }
- },
- {
- "id": "2ac03cf6-0326-454a-bed0-d8baef2bf30d",
- "type": "invocation",
- "data": {
- "id": "2ac03cf6-0326-454a-bed0-d8baef2bf30d",
- "type": "controlnet",
- "inputs": {
- "image": {
- "id": "1f683889-9f14-40c8-af29-4b991b211a3a",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "input",
- "label": ""
- },
- "control_model": {
- "id": "a933b21d-22c1-4e06-818f-15416b971282",
- "name": "control_model",
- "type": "ControlNetModelField",
- "fieldKind": "input",
- "label": "",
- "value": {
- "model_name": "qrcode_monster",
- "base_model": "sd-1"
- }
- },
- "control_weight": {
- "id": "198a0825-e55e-4496-bc54-c3d7b02f3d75",
- "name": "control_weight",
- "type": "FloatPolymorphic",
- "fieldKind": "input",
- "label": "",
- "value": 1.4
- },
- "begin_step_percent": {
- "id": "c85ce42f-22af-42a0-8993-676002fb275e",
- "name": "begin_step_percent",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "end_step_percent": {
- "id": "a61a65c4-9e6f-4fe2-96a5-1294d17ec6e4",
- "name": "end_step_percent",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 1
- },
- "control_mode": {
- "id": "1aa45cfa-0249-46b7-bf24-3e38e92f5fa0",
- "name": "control_mode",
- "type": "enum",
- "fieldKind": "input",
- "label": "",
- "value": "balanced"
- },
- "resize_mode": {
- "id": "a89d3cb9-a141-4cea-bb49-977bf267377b",
- "name": "resize_mode",
- "type": "enum",
- "fieldKind": "input",
- "label": "",
- "value": "just_resize"
- }
- },
- "outputs": {
- "control": {
- "id": "c9a1fc7e-cb25-45a9-adff-1a97c9ff04d6",
- "name": "control",
- "type": "ControlField",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 508,
- "position": {
- "x": 1165.434407461108,
- "y": 1862.916856351665
- }
- },
- {
- "id": "28542b66-5a00-4780-a318-0a036d2df914",
- "type": "invocation",
- "data": {
- "id": "28542b66-5a00-4780-a318-0a036d2df914",
- "type": "l2i",
- "inputs": {
- "metadata": {
- "id": "a38e8f55-7f2c-4fcc-a71f-d51e2eb0374a",
- "name": "metadata",
- "type": "MetadataField",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "80e97bc8-e716-4175-9115-5b58495aa30c",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "vae": {
- "id": "5641bce6-ac2b-47eb-bb32-2f290026b7e1",
- "name": "vae",
- "type": "VaeField",
- "fieldKind": "input",
- "label": ""
- },
- "tiled": {
- "id": "9e75eb16-ae48-47ed-b180-e0409d377436",
- "name": "tiled",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- },
- "fp32": {
- "id": "0518b0ce-ee37-437b-8437-cc2976a3279f",
- "name": "fp32",
- "type": "boolean",
- "fieldKind": "input",
- "label": "",
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "ec2ff985-a7eb-401f-92c4-1217cddad6a2",
- "name": "image",
- "type": "ImageField",
- "fieldKind": "output"
- },
- "width": {
- "id": "ba1d1720-6d67-4eca-9e9d-b97d08636774",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "10bcf8f4-6394-422f-b0c0-51680f3bfb25",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 267,
- "position": {
- "x": 2110.8415693683014,
- "y": 1487.253341116115
- }
- },
- {
- "id": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "type": "invocation",
- "data": {
- "id": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "type": "denoise_latents",
- "inputs": {
- "positive_conditioning": {
- "id": "8e6aceaa-a986-4ab2-9c04-5b1027b3daf6",
- "name": "positive_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "negative_conditioning": {
- "id": "fbbaa712-ca1a-420b-9016-763f2a29d68c",
- "name": "negative_conditioning",
- "type": "ConditioningField",
- "fieldKind": "input",
- "label": ""
- },
- "noise": {
- "id": "a3b3d5d2-c0f9-4b89-a9b3-8de9418f7bb5",
- "name": "noise",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "steps": {
- "id": "e491e664-2f8c-4f49-b3e4-57b051fbb9c5",
- "name": "steps",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 10
- },
- "cfg_scale": {
- "id": "f0318abd-ed65-4cad-86a7-48d1c19a6d14",
- "name": "cfg_scale",
- "type": "FloatPolymorphic",
- "fieldKind": "input",
- "label": "",
- "value": 7.5
- },
- "denoising_start": {
- "id": "f7c24c51-496f-44c4-836a-c734e529fec0",
- "name": "denoising_start",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "denoising_end": {
- "id": "54f7656a-fb0d-4d9e-a459-f700f7dccd2e",
- "name": "denoising_end",
- "type": "float",
- "fieldKind": "input",
- "label": "",
- "value": 1
- },
- "scheduler": {
- "id": "363ee440-040d-499b-bf84-bf5391b08681",
- "name": "scheduler",
- "type": "Scheduler",
- "fieldKind": "input",
- "label": "",
- "value": "euler"
- },
- "unet": {
- "id": "5c93d4e5-1064-4700-ab1d-d12e1e9b5ba7",
- "name": "unet",
- "type": "UNetField",
- "fieldKind": "input",
- "label": ""
- },
- "control": {
- "id": "e1948eb3-7407-43b0-93e3-139470f186b7",
- "name": "control",
- "type": "ControlPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "ip_adapter": {
- "id": "5675b2c3-adfb-49ee-b33c-26bdbfab1fed",
- "name": "ip_adapter",
- "type": "IPAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "t2i_adapter": {
- "id": "89cd4ab3-3bfc-4063-9de5-91d42305c651",
- "name": "t2i_adapter",
- "type": "T2IAdapterPolymorphic",
- "fieldKind": "input",
- "label": ""
- },
- "latents": {
- "id": "ec01df90-5042-418d-b6d6-86b251c13770",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "input",
- "label": ""
- },
- "denoise_mask": {
- "id": "561cde00-cb20-42ae-9bd3-4f477f73fbe1",
- "name": "denoise_mask",
- "type": "DenoiseMaskField",
- "fieldKind": "input",
- "label": ""
- }
- },
- "outputs": {
- "latents": {
- "id": "f9addefe-efcc-4e01-8945-6ebbc934b002",
- "name": "latents",
- "type": "LatentsField",
- "fieldKind": "output"
- },
- "width": {
- "id": "6d48f78b-d681-422a-8677-0111bd0625f1",
- "name": "width",
- "type": "integer",
- "fieldKind": "output"
- },
- "height": {
- "id": "f25997b8-6316-44ce-b696-b82e4ed51ae5",
- "name": "height",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": true,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": true,
- "version": "1.4.0"
- },
- "width": 320,
- "height": 646,
- "position": {
- "x": 1597.9598293300219,
- "y": 1420.4637727891632
- }
- },
- {
- "id": "59349822-af20-4e0e-a53f-3ba135d00c3f",
- "type": "invocation",
- "data": {
- "id": "59349822-af20-4e0e-a53f-3ba135d00c3f",
- "type": "rand_int",
- "inputs": {
- "low": {
- "id": "051f22f9-2d4f-414f-bc51-84af2d626efa",
- "name": "low",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 0
- },
- "high": {
- "id": "77206186-f264-4224-9589-f925cf903dc9",
- "name": "high",
- "type": "integer",
- "fieldKind": "input",
- "label": "",
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "a7ed9387-3a24-4d34-b7c5-f713bd544ab1",
- "name": "value",
- "type": "integer",
- "fieldKind": "output"
- }
- },
- "label": "",
- "isOpen": false,
- "notes": "",
- "embedWorkflow": false,
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0"
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 1178.16746986153,
- "y": 1663.9433412808876
- }
- }
- ],
- "edges": [
- {
- "source": "59349822-af20-4e0e-a53f-3ba135d00c3f",
- "target": "280fd8a7-3b0c-49fe-8be4-6246e08b6c9a",
- "id": "59349822-af20-4e0e-a53f-3ba135d00c3f-280fd8a7-3b0c-49fe-8be4-6246e08b6c9a-collapsed",
- "type": "collapsed"
- },
- {
- "source": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "sourceHandle": "clip",
- "target": "aca3b054-bfba-4392-bd20-6476f59504df",
- "targetHandle": "clip",
- "id": "reactflow__edge-610384f1-6f0c-4847-a9a2-37ce7f456ed1clip-aca3b054-bfba-4392-bd20-6476f59504dfclip",
- "type": "default"
- },
- {
- "source": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "sourceHandle": "clip",
- "target": "3db7cee0-31e2-4a3d-94a1-268cb16177dd",
- "targetHandle": "clip",
- "id": "reactflow__edge-610384f1-6f0c-4847-a9a2-37ce7f456ed1clip-3db7cee0-31e2-4a3d-94a1-268cb16177ddclip",
- "type": "default"
- },
- {
- "source": "a6cc0986-f928-4a7e-8d44-ba2d4b36f54a",
- "sourceHandle": "image",
- "target": "2ac03cf6-0326-454a-bed0-d8baef2bf30d",
- "targetHandle": "image",
- "id": "reactflow__edge-a6cc0986-f928-4a7e-8d44-ba2d4b36f54aimage-2ac03cf6-0326-454a-bed0-d8baef2bf30dimage",
- "type": "default"
- },
- {
- "source": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "sourceHandle": "vae",
- "target": "28542b66-5a00-4780-a318-0a036d2df914",
- "targetHandle": "vae",
- "id": "reactflow__edge-610384f1-6f0c-4847-a9a2-37ce7f456ed1vae-28542b66-5a00-4780-a318-0a036d2df914vae",
- "type": "default"
- },
- {
- "source": "280fd8a7-3b0c-49fe-8be4-6246e08b6c9a",
- "sourceHandle": "noise",
- "target": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "targetHandle": "noise",
- "id": "reactflow__edge-280fd8a7-3b0c-49fe-8be4-6246e08b6c9anoise-9755ae4c-ef30-4db3-80f6-a31f98979a11noise",
- "type": "default"
- },
- {
- "source": "3db7cee0-31e2-4a3d-94a1-268cb16177dd",
- "sourceHandle": "conditioning",
- "target": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "targetHandle": "negative_conditioning",
- "id": "reactflow__edge-3db7cee0-31e2-4a3d-94a1-268cb16177ddconditioning-9755ae4c-ef30-4db3-80f6-a31f98979a11negative_conditioning",
- "type": "default"
- },
- {
- "source": "aca3b054-bfba-4392-bd20-6476f59504df",
- "sourceHandle": "conditioning",
- "target": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "targetHandle": "positive_conditioning",
- "id": "reactflow__edge-aca3b054-bfba-4392-bd20-6476f59504dfconditioning-9755ae4c-ef30-4db3-80f6-a31f98979a11positive_conditioning",
- "type": "default"
- },
- {
- "source": "610384f1-6f0c-4847-a9a2-37ce7f456ed1",
- "sourceHandle": "unet",
- "target": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "targetHandle": "unet",
- "id": "reactflow__edge-610384f1-6f0c-4847-a9a2-37ce7f456ed1unet-9755ae4c-ef30-4db3-80f6-a31f98979a11unet",
- "type": "default"
- },
- {
- "source": "2ac03cf6-0326-454a-bed0-d8baef2bf30d",
- "sourceHandle": "control",
- "target": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "targetHandle": "control",
- "id": "reactflow__edge-2ac03cf6-0326-454a-bed0-d8baef2bf30dcontrol-9755ae4c-ef30-4db3-80f6-a31f98979a11control",
- "type": "default"
- },
- {
- "source": "9755ae4c-ef30-4db3-80f6-a31f98979a11",
- "sourceHandle": "latents",
- "target": "28542b66-5a00-4780-a318-0a036d2df914",
- "targetHandle": "latents",
- "id": "reactflow__edge-9755ae4c-ef30-4db3-80f6-a31f98979a11latents-28542b66-5a00-4780-a318-0a036d2df914latents",
- "type": "default"
- },
- {
- "source": "59349822-af20-4e0e-a53f-3ba135d00c3f",
- "sourceHandle": "value",
- "target": "280fd8a7-3b0c-49fe-8be4-6246e08b6c9a",
- "targetHandle": "seed",
- "id": "reactflow__edge-59349822-af20-4e0e-a53f-3ba135d00c3fvalue-280fd8a7-3b0c-49fe-8be4-6246e08b6c9aseed",
- "type": "default"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/SDXL_Text_to_Image.json b/docs/workflows/SDXL_Text_to_Image.json
deleted file mode 100644
index ef7810c1533..00000000000
--- a/docs/workflows/SDXL_Text_to_Image.json
+++ /dev/null
@@ -1,1320 +0,0 @@
-{
- "name": "SDXL Text to Image",
- "author": "InvokeAI",
- "description": "Sample text to image workflow for SDXL",
- "version": "1.0.1",
- "contact": "invoke@invoke.ai",
- "tags": "text2image, SDXL, default",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
- "fieldName": "value"
- },
- {
- "nodeId": "719dabe8-8297-4749-aea1-37be301cd425",
- "fieldName": "value"
- },
- {
- "nodeId": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "fieldName": "model"
- },
- {
- "nodeId": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "fieldName": "vae_model"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "3774ec24-a69e-4254-864c-097d07a6256f",
- "type": "invocation",
- "data": {
- "id": "3774ec24-a69e-4254-864c-097d07a6256f",
- "type": "string_join",
- "label": "Positive Style Concat",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "string_left": {
- "id": "8d84be5c-4a96-46ad-a92c-eaf6fcae4a69",
- "name": "string_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "string_right": {
- "id": "c8e2a881-f675-4c6b-865b-a0892473b750",
- "name": "string_right",
- "fieldKind": "input",
- "label": "Positive Style Concat",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- }
- },
- "outputs": {
- "value": {
- "id": "196fad08-73ea-4fe5-8cc3-b55fd3cf40e5",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 750,
- "y": -225
- }
- },
- {
- "id": "719dabe8-8297-4749-aea1-37be301cd425",
- "type": "invocation",
- "data": {
- "id": "719dabe8-8297-4749-aea1-37be301cd425",
- "type": "string",
- "label": "Negative Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "value": {
- "id": "744a5f7c-6e3a-4fbc-ac66-ba0cf8559eeb",
- "name": "value",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": "photograph"
- }
- },
- "outputs": {
- "value": {
- "id": "3e0ddf7a-a5de-4dad-b726-5d0cb4e0baa6",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "width": 320,
- "height": 258,
- "position": {
- "x": 750,
- "y": -125
- }
- },
- {
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "invocation",
- "data": {
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "sdxl_compel_prompt",
- "label": "SDXL Negative Compel Prompt",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5a6889e6-95cb-462f-8f4a-6b93ae7afaec",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "style": {
- "id": "f240d0e6-3a1c-4320-af23-20ebb707c276",
- "name": "style",
- "fieldKind": "input",
- "label": "Negative Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "05af07b0-99a0-4a68-8ad2-697bbdb7fc7e",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "2c771996-a998-43b7-9dd3-3792664d4e5b",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "66519dca-a151-4e3e-ae1f-88f1f9877bde",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "349cf2e9-f3d0-4e16-9ae2-7097d25b6a51",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "target_width": {
- "id": "44499347-7bd6-4a73-99d6-5a982786db05",
- "name": "target_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "target_height": {
- "id": "fda359b0-ab80-4f3c-805b-c9f61319d7d2",
- "name": "target_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "clip": {
- "id": "b447adaf-a649-4a76-a827-046a9fc8d89b",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "86ee4e32-08f9-4baa-9163-31d93f5c0187",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "7c10118e-7b4e-4911-b98e-d3ba6347dfd0",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 750,
- "y": 200
- }
- },
- {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "invocation",
- "data": {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "noise",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "6431737c-918a-425d-a3b4-5d57e2f35d4d",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "38fc5b66-fe6e-47c8-bba9-daf58e454ed7",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "height": {
- "id": "16298330-e2bf-4872-a514-d6923df53cbb",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "use_cpu": {
- "id": "c7c436d3-7a7a-4e76-91e4-c6deb271623c",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "50f650dc-0184-4e23-a927-0497a96fe954",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "bb8a452b-133d-42d1-ae4a-3843d7e4109a",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "35cfaa12-3b8b-4b7a-a884-327ff3abddd9",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 388,
- "position": {
- "x": 375,
- "y": 0
- }
- },
- {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "invocation",
- "data": {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "rand_int",
- "label": "Random Seed",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "low": {
- "id": "3ec65a37-60ba-4b6c-a0b2-553dd7a84b84",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "085f853a-1a5f-494d-8bec-e4ba29a3f2d1",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "812ade4d-7699-4261-b9fc-a6c9d2ab55ee",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 375,
- "y": -50
- }
- },
- {
- "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "type": "invocation",
- "data": {
- "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "type": "sdxl_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "39f9e799-bc95-4318-a200-30eed9e60c42",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SDXLMainModelField"
- },
- "value": null
- }
- },
- "outputs": {
- "unet": {
- "id": "2626a45e-59aa-4609-b131-2d45c5eaed69",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "7c9c42fa-93d5-4639-ab8b-c4d9b0559baf",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "0dafddcf-a472-49c1-a47c-7b8fab4c8bc9",
- "name": "clip2",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "ee6a6997-1b3c-4ff3-99ce-1e7bfba2750c",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 257,
- "position": {
- "x": 375,
- "y": -500
- }
- },
- {
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "invocation",
- "data": {
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "sdxl_compel_prompt",
- "label": "SDXL Positive Compel Prompt",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5a6889e6-95cb-462f-8f4a-6b93ae7afaec",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "style": {
- "id": "f240d0e6-3a1c-4320-af23-20ebb707c276",
- "name": "style",
- "fieldKind": "input",
- "label": "Positive Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "05af07b0-99a0-4a68-8ad2-697bbdb7fc7e",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "2c771996-a998-43b7-9dd3-3792664d4e5b",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "66519dca-a151-4e3e-ae1f-88f1f9877bde",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "349cf2e9-f3d0-4e16-9ae2-7097d25b6a51",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "target_width": {
- "id": "44499347-7bd6-4a73-99d6-5a982786db05",
- "name": "target_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "target_height": {
- "id": "fda359b0-ab80-4f3c-805b-c9f61319d7d2",
- "name": "target_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "clip": {
- "id": "b447adaf-a649-4a76-a827-046a9fc8d89b",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "86ee4e32-08f9-4baa-9163-31d93f5c0187",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "7c10118e-7b4e-4911-b98e-d3ba6347dfd0",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 750,
- "y": -175
- }
- },
- {
- "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
- "type": "invocation",
- "data": {
- "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": false,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "88971324-3fdb-442d-b8b7-7612478a8622",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "da0e40cb-c49f-4fa5-9856-338b91a65f6b",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "ae5164ce-1710-4ec5-a83a-6113a0d1b5c0",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "2ccfd535-1a7b-4ecf-84db-9430a64fb3d7",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "64f07d5a-54a2-429c-8c5b-0c2a3a8e5cd5",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "9b281eaa-6504-407d-a5ca-1e5e8020a4bf",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "98e545f3-b53b-490d-b94d-bed9418ccc75",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "4a74bd43-d7f7-4c7f-bb3b-d09bb2992c46",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 266,
- "position": {
- "x": 1475,
- "y": -500
- }
- },
- {
- "id": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "invocation",
- "data": {
- "id": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "29b73dfa-a06e-4b4a-a844-515b9eb93a81",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "a81e6f5b-f4de-4919-b483-b6e2f067465a",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "4ba06bb7-eb45-4fb9-9984-31001b545587",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "36ee8a45-ca69-44bc-9bc3-aa881e6045c0",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 32
- },
- "cfg_scale": {
- "id": "2a2024e0-a736-46ec-933c-c1c1ebe96943",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 6
- },
- "denoising_start": {
- "id": "be219d5e-41b7-430a-8fb5-bc21a31ad219",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "3adfb7ae-c9f7-4a40-b6e0-4c2050bd1a99",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "14423e0d-7215-4ee0-b065-f9e95eaa8d7d",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "dpmpp_2m_sde_k"
- },
- "unet": {
- "id": "e73bbf98-6489-492b-b83c-faed215febac",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "dab351b3-0c86-4ea5-9782-4e8edbfb0607",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "192daea0-a90a-43cc-a2ee-0114a8e90318",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "ee386a55-d4c7-48c1-ac57-7bc4e3aada7a",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "106bbe8d-e641-4034-9a39-d4e82c298da1",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "3a922c6a-3d8c-4c9e-b3ec-2f4d81cda077",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "cd7ce032-835f-495f-8b45-d57272f33132",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "6260b84f-8361-470a-98d8-5b22a45c2d8c",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "aede0ecf-25b6-46be-aa30-b77f79715deb",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "519abf62-d475-48ef-ab8f-66136bc0e499",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 702,
- "position": {
- "x": 1125,
- "y": -500
- }
- },
- {
- "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "type": "invocation",
- "data": {
- "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "type": "vae_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "vae_model": {
- "id": "28a17000-b629-49c6-b945-77c591cf7440",
- "name": "vae_model",
- "fieldKind": "input",
- "label": "VAE (use the FP16 model)",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VAEModelField"
- },
- "value": null
- }
- },
- "outputs": {
- "vae": {
- "id": "a34892b6-ba6d-44eb-8a68-af1f40a84186",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 161,
- "position": {
- "x": 375,
- "y": -225
- }
- },
- {
- "id": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
- "type": "invocation",
- "data": {
- "id": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
- "type": "string",
- "label": "Positive Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "value": {
- "id": "744a5f7c-6e3a-4fbc-ac66-ba0cf8559eeb",
- "name": "value",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": "Super cute tiger cub, fierce, traditional chinese watercolor"
- }
- },
- "outputs": {
- "value": {
- "id": "3e0ddf7a-a5de-4dad-b726-5d0cb4e0baa6",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "width": 320,
- "height": 258,
- "position": {
- "x": 750,
- "y": -500
- }
- },
- {
- "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650",
- "type": "invocation",
- "data": {
- "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650",
- "type": "string_join",
- "label": "Negative Style Concat",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "inputs": {
- "string_left": {
- "id": "8d84be5c-4a96-46ad-a92c-eaf6fcae4a69",
- "name": "string_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "string_right": {
- "id": "c8e2a881-f675-4c6b-865b-a0892473b750",
- "name": "string_right",
- "fieldKind": "input",
- "label": "Negative Style Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- }
- },
- "outputs": {
- "value": {
- "id": "196fad08-73ea-4fe5-8cc3-b55fd3cf40e5",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 750,
- "y": 150
- }
- }
- ],
- "edges": [
- {
- "id": "3774ec24-a69e-4254-864c-097d07a6256f-faf965a4-7530-427b-b1f3-4ba6505c2a08-collapsed",
- "source": "3774ec24-a69e-4254-864c-097d07a6256f",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "collapsed"
- },
- {
- "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204-collapsed",
- "source": "ad8fa655-3a76-43d0-9c02-4d7644dea650",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "collapsed"
- },
- {
- "id": "reactflow__edge-ea94bc37-d995-4a83-aa99-4af42479f2f2value-55705012-79b9-4aac-9f26-c0b10309785bseed",
- "source": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "target": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip-faf965a4-7530-427b-b1f3-4ba6505c2a08clip",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip2-faf965a4-7530-427b-b1f3-4ba6505c2a08clip2",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "default",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204clip",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip2-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204clip2",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "default",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22unet-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbunet",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-faf965a4-7530-427b-b1f3-4ba6505c2a08conditioning-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbpositive_conditioning",
- "source": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204conditioning-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbnegative_conditioning",
- "source": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-55705012-79b9-4aac-9f26-c0b10309785bnoise-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbnoise",
- "source": "55705012-79b9-4aac-9f26-c0b10309785b",
- "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfblatents-63e91020-83b2-4f35-b174-ad9692aabb48latents",
- "source": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb",
- "target": "63e91020-83b2-4f35-b174-ad9692aabb48",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-0093692f-9cf4-454d-a5b8-62f0e3eb3bb8vae-63e91020-83b2-4f35-b174-ad9692aabb48vae",
- "source": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "target": "63e91020-83b2-4f35-b174-ad9692aabb48",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- },
- {
- "id": "reactflow__edge-ade2c0d3-0384-4157-b39b-29ce429cfa15value-faf965a4-7530-427b-b1f3-4ba6505c2a08prompt",
- "source": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "prompt"
- },
- {
- "id": "reactflow__edge-719dabe8-8297-4749-aea1-37be301cd425value-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204prompt",
- "source": "719dabe8-8297-4749-aea1-37be301cd425",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "prompt"
- },
- {
- "id": "reactflow__edge-719dabe8-8297-4749-aea1-37be301cd425value-ad8fa655-3a76-43d0-9c02-4d7644dea650string_left",
- "source": "719dabe8-8297-4749-aea1-37be301cd425",
- "target": "ad8fa655-3a76-43d0-9c02-4d7644dea650",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "string_left"
- },
- {
- "id": "reactflow__edge-ad8fa655-3a76-43d0-9c02-4d7644dea650value-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204style",
- "source": "ad8fa655-3a76-43d0-9c02-4d7644dea650",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "style"
- },
- {
- "id": "reactflow__edge-ade2c0d3-0384-4157-b39b-29ce429cfa15value-3774ec24-a69e-4254-864c-097d07a6256fstring_left",
- "source": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
- "target": "3774ec24-a69e-4254-864c-097d07a6256f",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "string_left"
- },
- {
- "id": "reactflow__edge-3774ec24-a69e-4254-864c-097d07a6256fvalue-faf965a4-7530-427b-b1f3-4ba6505c2a08style",
- "source": "3774ec24-a69e-4254-864c-097d07a6256f",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "style"
- }
- ]
-}
diff --git a/docs/workflows/SDXL_w_Refiner_Text_to_Image.json b/docs/workflows/SDXL_w_Refiner_Text_to_Image.json
deleted file mode 100644
index 634b3fac4b8..00000000000
--- a/docs/workflows/SDXL_w_Refiner_Text_to_Image.json
+++ /dev/null
@@ -1,1900 +0,0 @@
-{
- "name": "SDXL w/Refiner Text to Image",
- "author": "InvokeAI",
- "description": "Sample text to image workflow for SDXL with the refiner",
- "version": "1.0.1",
- "contact": "invoke@invoke.ai",
- "tags": "text2image, SDXL, default, refiner",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "fieldName": "model"
- },
- {
- "nodeId": "06a30867-1e9d-461f-bd58-14a63cc997dd",
- "fieldName": "scheduler"
- },
- {
- "nodeId": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "fieldName": "model"
- },
- {
- "nodeId": "b2b35add-929d-4538-aecb-02c661768b29",
- "fieldName": "value"
- },
- {
- "nodeId": "f1a6a160-4c36-4902-8eeb-8b1c23e81bc8",
- "fieldName": "value"
- },
- {
- "nodeId": "5639e3bc-b769-4ae5-9262-72db703c5a7b",
- "fieldName": "value"
- },
- {
- "nodeId": "8d54b9db-3662-43af-8369-9a277e063f3b",
- "fieldName": "value"
- }
- ],
- "meta": {
- "category": "user",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "b2b35add-929d-4538-aecb-02c661768b29",
- "type": "invocation",
- "data": {
- "id": "b2b35add-929d-4538-aecb-02c661768b29",
- "type": "string",
- "label": "Positive Prompt",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "89854c84-cbc1-4b60-921d-4bade11cab66",
- "name": "value",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": "super cute tiger"
- }
- },
- "outputs": {
- "value": {
- "id": "3617404e-e483-4e2e-8550-7080a1ef283f",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": -75
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "8d54b9db-3662-43af-8369-9a277e063f3b",
- "type": "invocation",
- "data": {
- "id": "8d54b9db-3662-43af-8369-9a277e063f3b",
- "type": "string",
- "label": "Negative Style",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "89854c84-cbc1-4b60-921d-4bade11cab66",
- "name": "value",
- "fieldKind": "input",
- "label": "Negative Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- }
- },
- "outputs": {
- "value": {
- "id": "3617404e-e483-4e2e-8550-7080a1ef283f",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": 75
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "f1a6a160-4c36-4902-8eeb-8b1c23e81bc8",
- "type": "invocation",
- "data": {
- "id": "f1a6a160-4c36-4902-8eeb-8b1c23e81bc8",
- "type": "string",
- "label": "Postive Style",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "89854c84-cbc1-4b60-921d-4bade11cab66",
- "name": "value",
- "fieldKind": "input",
- "label": "Positive Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- }
- },
- "outputs": {
- "value": {
- "id": "3617404e-e483-4e2e-8550-7080a1ef283f",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": -25
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1",
- "type": "invocation",
- "data": {
- "id": "fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1",
- "type": "sdxl_refiner_compel_prompt",
- "label": "SDXL Refiner Negative Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "style": {
- "id": "c6f91ecf-370f-44d0-8243-63724e75510a",
- "name": "style",
- "fieldKind": "input",
- "label": "Negative Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "956f6eca-8324-41eb-a8dd-fa9b34164ca6",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "a41bb3a1-7664-4dac-b6ae-6f4dff3828a9",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "81936e19-0ae7-4006-9d7c-e359fc7c7d15",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "be94ddb8-88cc-4d6b-a2c0-f2b43143bfa1",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "aesthetic_score": {
- "id": "60f380de-87b4-4535-b3ef-545a6e57283e",
- "name": "aesthetic_score",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 6
- },
- "clip2": {
- "id": "773c4054-c005-46ad-92c4-5c1fa4506041",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "63e16bfa-59d8-4d6f-abda-ad979b26adb5",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "position": {
- "x": 1625,
- "y": -925
- },
- "width": 320,
- "height": 483
- },
- {
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "invocation",
- "data": {
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "type": "sdxl_compel_prompt",
- "label": "SDXL Negative Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5a6889e6-95cb-462f-8f4a-6b93ae7afaec",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "style": {
- "id": "f240d0e6-3a1c-4320-af23-20ebb707c276",
- "name": "style",
- "fieldKind": "input",
- "label": "Negative Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "05af07b0-99a0-4a68-8ad2-697bbdb7fc7e",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "2c771996-a998-43b7-9dd3-3792664d4e5b",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "66519dca-a151-4e3e-ae1f-88f1f9877bde",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "349cf2e9-f3d0-4e16-9ae2-7097d25b6a51",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "target_width": {
- "id": "44499347-7bd6-4a73-99d6-5a982786db05",
- "name": "target_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "target_height": {
- "id": "fda359b0-ab80-4f3c-805b-c9f61319d7d2",
- "name": "target_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "clip": {
- "id": "b447adaf-a649-4a76-a827-046a9fc8d89b",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "86ee4e32-08f9-4baa-9163-31d93f5c0187",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "7c10118e-7b4e-4911-b98e-d3ba6347dfd0",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "position": {
- "x": 900,
- "y": -925
- },
- "width": 320,
- "height": 699
- },
- {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "invocation",
- "data": {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "noise",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "6431737c-918a-425d-a3b4-5d57e2f35d4d",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "38fc5b66-fe6e-47c8-bba9-daf58e454ed7",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "height": {
- "id": "16298330-e2bf-4872-a514-d6923df53cbb",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "use_cpu": {
- "id": "c7c436d3-7a7a-4e76-91e4-c6deb271623c",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "50f650dc-0184-4e23-a927-0497a96fe954",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "bb8a452b-133d-42d1-ae4a-3843d7e4109a",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "35cfaa12-3b8b-4b7a-a884-327ff3abddd9",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "position": {
- "x": 1275,
- "y": -200
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "invocation",
- "data": {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "rand_int",
- "label": "Random Seed",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "low": {
- "id": "3ec65a37-60ba-4b6c-a0b2-553dd7a84b84",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "085f853a-1a5f-494d-8bec-e4ba29a3f2d1",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "812ade4d-7699-4261-b9fc-a6c9d2ab55ee",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "position": {
- "x": 1275,
- "y": -250
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "type": "invocation",
- "data": {
- "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "type": "sdxl_model_loader",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "39f9e799-bc95-4318-a200-30eed9e60c42",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SDXLMainModelField"
- },
- "value": {
- "model_name": "stable-diffusion-xl-base-1.0",
- "base_model": "sdxl",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "2626a45e-59aa-4609-b131-2d45c5eaed69",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "7c9c42fa-93d5-4639-ab8b-c4d9b0559baf",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "0dafddcf-a472-49c1-a47c-7b8fab4c8bc9",
- "name": "clip2",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "ee6a6997-1b3c-4ff3-99ce-1e7bfba2750c",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": 175
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "invocation",
- "data": {
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "type": "sdxl_compel_prompt",
- "label": "SDXL Positive Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "5a6889e6-95cb-462f-8f4a-6b93ae7afaec",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "style": {
- "id": "f240d0e6-3a1c-4320-af23-20ebb707c276",
- "name": "style",
- "fieldKind": "input",
- "label": "Positive Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "05af07b0-99a0-4a68-8ad2-697bbdb7fc7e",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "2c771996-a998-43b7-9dd3-3792664d4e5b",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "66519dca-a151-4e3e-ae1f-88f1f9877bde",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "349cf2e9-f3d0-4e16-9ae2-7097d25b6a51",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "target_width": {
- "id": "44499347-7bd6-4a73-99d6-5a982786db05",
- "name": "target_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "target_height": {
- "id": "fda359b0-ab80-4f3c-805b-c9f61319d7d2",
- "name": "target_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "clip": {
- "id": "b447adaf-a649-4a76-a827-046a9fc8d89b",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "clip2": {
- "id": "86ee4e32-08f9-4baa-9163-31d93f5c0187",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "7c10118e-7b4e-4911-b98e-d3ba6347dfd0",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "position": {
- "x": 550,
- "y": -925
- },
- "width": 320,
- "height": 699
- },
- {
- "id": "f0e06b70-9f53-44e3-8f5f-63d813b6b579",
- "type": "invocation",
- "data": {
- "id": "f0e06b70-9f53-44e3-8f5f-63d813b6b579",
- "type": "sdxl_refiner_compel_prompt",
- "label": "SDXL Refiner Positive Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "style": {
- "id": "c6f91ecf-370f-44d0-8243-63724e75510a",
- "name": "style",
- "fieldKind": "input",
- "label": "Positive Style",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "original_width": {
- "id": "956f6eca-8324-41eb-a8dd-fa9b34164ca6",
- "name": "original_width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "original_height": {
- "id": "a41bb3a1-7664-4dac-b6ae-6f4dff3828a9",
- "name": "original_height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 1024
- },
- "crop_top": {
- "id": "81936e19-0ae7-4006-9d7c-e359fc7c7d15",
- "name": "crop_top",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "crop_left": {
- "id": "be94ddb8-88cc-4d6b-a2c0-f2b43143bfa1",
- "name": "crop_left",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "aesthetic_score": {
- "id": "60f380de-87b4-4535-b3ef-545a6e57283e",
- "name": "aesthetic_score",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 6
- },
- "clip2": {
- "id": "773c4054-c005-46ad-92c4-5c1fa4506041",
- "name": "clip2",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "63e16bfa-59d8-4d6f-abda-ad979b26adb5",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "position": {
- "x": 1275,
- "y": -925
- },
- "width": 320,
- "height": 483
- },
- {
- "id": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "type": "invocation",
- "data": {
- "id": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "type": "sdxl_refiner_model_loader",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "2f09431c-096b-4848-b580-b37be773839d",
- "name": "model",
- "fieldKind": "input",
- "label": "Refiner Model",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SDXLRefinerModelField"
- },
- "value": {
- "model_name": "stable-diffusion-xl-refiner-1.0",
- "base_model": "sdxl-refiner",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "c06b335a-7f65-4165-9ca2-40107eb9c85b",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip2": {
- "id": "81ec105e-cc1f-4b78-a5bb-8df3a4dd2574",
- "name": "clip2",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "f516feab-873d-47ec-a946-b8f15eed4bed",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": 225
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "5639e3bc-b769-4ae5-9262-72db703c5a7b",
- "type": "invocation",
- "data": {
- "id": "5639e3bc-b769-4ae5-9262-72db703c5a7b",
- "type": "string",
- "label": "Negative Prompt",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "value": {
- "id": "89854c84-cbc1-4b60-921d-4bade11cab66",
- "name": "value",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- }
- },
- "outputs": {
- "value": {
- "id": "3617404e-e483-4e2e-8550-7080a1ef283f",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": 25
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "06a30867-1e9d-461f-bd58-14a63cc997dd",
- "type": "invocation",
- "data": {
- "id": "06a30867-1e9d-461f-bd58-14a63cc997dd",
- "type": "scheduler",
- "label": "",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "scheduler": {
- "id": "0be5a5a0-3388-41e1-a6c4-10d414d8fe5b",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- }
- },
- "outputs": {
- "scheduler": {
- "id": "cafdef9d-61cd-4f43-be91-356a1d65afca",
- "name": "scheduler",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- }
- }
- }
- },
- "position": {
- "x": 700,
- "y": 125
- },
- "width": 320,
- "height": 24
- },
- {
- "id": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "type": "invocation",
- "data": {
- "id": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.1",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "73b2ebc2-4a56-4809-b8ab-b78fde786961",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "04d1bfbb-6cdc-4c16-8e08-290ba86ca8ba",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "39ea4659-ea69-415f-85c0-a06f94d53e14",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "dfd3c295-adae-499a-8c94-3c6c6d9ece0e",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 10
- },
- "cfg_scale": {
- "id": "2ae0c196-8c94-4ea8-a9fc-1be06938a0c3",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "3d085ec1-14de-4eef-9853-2edf5d81daac",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "1a820924-15ca-4ba5-b981-6b588e486a5b",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.8
- },
- "scheduler": {
- "id": "d0d19fab-5001-4c5d-b664-031df1a65311",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- },
- "unet": {
- "id": "efbdecd1-5c07-420c-bd58-52de43fcde4c",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "e1a457c4-5546-4c02-83e1-092776b27cd1",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "d4082d78-7f17-4f87-af05-5a76129737ba",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "4841595c-f81b-440a-9377-fe89b26b42ac",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "679083b2-f137-416f-a9d8-ec38f6263c1f",
- "name": "cfg_rescale_multiplier",
- "type": {
- "name": "FloatField",
- "isCollection": false,
- "isCollectionOrScalar": false
- },
- "label": "",
- "fieldKind": "input",
- "value": 0
- },
- "latents": {
- "id": "60bbfc7e-6641-4354-b678-12029c580aa9",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "b2876171-e4c5-45cf-a352-852047c902fc",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "62705a29-cc3a-4154-8f62-a8f821daf861",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "eb2e2312-1e64-4008-a64f-6783d49dde29",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "7bd8d012-edcf-4def-98eb-7ebdd724c7c5",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "position": {
- "x": 1269.2683722842958,
- "y": -119.4839111990423
- },
- "width": 320,
- "height": 614
- },
- {
- "id": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "type": "invocation",
- "data": {
- "id": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.1",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "a9c932a9-6164-4333-bade-3909c9a3ce59",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "a57fced5-aca6-40c9-8197-ce4f01433111",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "b4cbec14-c24e-4ec2-bda3-8fc19c089717",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "24dd36f7-fdf1-40c9-945c-216471b44a2f",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 10
- },
- "cfg_scale": {
- "id": "5f3a7f0c-5088-49e9-b490-75822d0c20cc",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "b326ffde-625c-49c5-b5e1-90b79df80979",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0.8
- },
- "denoising_end": {
- "id": "d4a458ef-5576-4c5d-8e6d-ee04c9c7c4dc",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "95aba2d0-c470-44f2-a25c-a192600be6da",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "euler"
- },
- "unet": {
- "id": "68a8636e-3b2f-4a95-bd05-d86a01edb74a",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "508e68a6-1cfc-4121-baed-b829b2886474",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "e976e72f-8bd1-44d4-ad75-8410db221e3f",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "38e15c99-ff72-443a-bddc-440fab9ccefc",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "30c6aca9-333d-49fa-be99-89daf82fe850",
- "name": "cfg_rescale_multiplier",
- "type": {
- "name": "FloatField",
- "isCollection": false,
- "isCollectionOrScalar": false
- },
- "label": "",
- "fieldKind": "input",
- "value": 0
- },
- "latents": {
- "id": "f6d628e8-05ca-4ee3-a5a4-35323ebeb853",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "e2385456-d127-4793-98ca-d93b4aee3481",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "a61efc39-ba21-468b-ae58-5922337cf399",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "9093043b-808b-4ac6-ab18-7d721a7e39d7",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "5ff472a4-ee22-4988-bcf7-7d6116a37e5a",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "position": {
- "x": 1672.552348276784,
- "y": -118.3156091718022
- },
- "width": 320,
- "height": 614
- },
- {
- "id": "17eb4b88-bdd8-4984-affa-26586b146866",
- "type": "invocation",
- "data": {
- "id": "17eb4b88-bdd8-4984-affa-26586b146866",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "04f49f70-8ee6-43e3-ac63-af02e5b34204",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "d27c4313-01db-45cb-b9f4-6a827d0d766a",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "3751b0f6-69f3-4f95-a7f9-476b2d31e9f9",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "07e5e79a-b452-4beb-b26f-715da2387ac7",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "afe954f6-ecaf-4eac-98ee-23f4d0eb7a6b",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- }
- },
- "outputs": {
- "image": {
- "id": "98515f37-9fe7-420e-839b-6e349d9407df",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "5203f61f-02db-423c-9c85-80aa20816dea",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "499e6152-c604-4dad-84c3-8c5b26a39919",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "position": {
- "x": 2045.2934900771834,
- "y": -362.2916292593367
- },
- "width": 320,
- "height": 225
- }
- ],
- "edges": [
- {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2-55705012-79b9-4aac-9f26-c0b10309785b-collapsed",
- "type": "collapsed",
- "source": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "target": "55705012-79b9-4aac-9f26-c0b10309785b"
- },
- {
- "id": "reactflow__edge-ea94bc37-d995-4a83-aa99-4af42479f2f2value-55705012-79b9-4aac-9f26-c0b10309785bseed",
- "type": "default",
- "source": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "target": "55705012-79b9-4aac-9f26-c0b10309785b",
- "sourceHandle": "value",
- "targetHandle": "seed"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip-faf965a4-7530-427b-b1f3-4ba6505c2a08clip",
- "type": "default",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip2-faf965a4-7530-427b-b1f3-4ba6505c2a08clip2",
- "type": "default",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204clip",
- "type": "default",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22clip2-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204clip2",
- "type": "default",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-62bdf243-d98f-4508-b6b5-c3af00ef49f0clip2-f0e06b70-9f53-44e3-8f5f-63d813b6b579clip2",
- "type": "default",
- "source": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "target": "f0e06b70-9f53-44e3-8f5f-63d813b6b579",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-62bdf243-d98f-4508-b6b5-c3af00ef49f0clip2-fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1clip2",
- "type": "default",
- "source": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "target": "fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1",
- "sourceHandle": "clip2",
- "targetHandle": "clip2"
- },
- {
- "id": "reactflow__edge-5639e3bc-b769-4ae5-9262-72db703c5a7bvalue-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204prompt",
- "type": "default",
- "source": "5639e3bc-b769-4ae5-9262-72db703c5a7b",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "sourceHandle": "value",
- "targetHandle": "prompt"
- },
- {
- "id": "reactflow__edge-f1a6a160-4c36-4902-8eeb-8b1c23e81bc8value-faf965a4-7530-427b-b1f3-4ba6505c2a08style",
- "type": "default",
- "source": "f1a6a160-4c36-4902-8eeb-8b1c23e81bc8",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "sourceHandle": "value",
- "targetHandle": "style"
- },
- {
- "id": "reactflow__edge-b2b35add-929d-4538-aecb-02c661768b29value-faf965a4-7530-427b-b1f3-4ba6505c2a08prompt",
- "type": "default",
- "source": "b2b35add-929d-4538-aecb-02c661768b29",
- "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "sourceHandle": "value",
- "targetHandle": "prompt"
- },
- {
- "id": "reactflow__edge-f1a6a160-4c36-4902-8eeb-8b1c23e81bc8value-f0e06b70-9f53-44e3-8f5f-63d813b6b579style",
- "type": "default",
- "source": "f1a6a160-4c36-4902-8eeb-8b1c23e81bc8",
- "target": "f0e06b70-9f53-44e3-8f5f-63d813b6b579",
- "sourceHandle": "value",
- "targetHandle": "style"
- },
- {
- "id": "reactflow__edge-8d54b9db-3662-43af-8369-9a277e063f3bvalue-fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1style",
- "type": "default",
- "source": "8d54b9db-3662-43af-8369-9a277e063f3b",
- "target": "fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1",
- "sourceHandle": "value",
- "targetHandle": "style"
- },
- {
- "id": "reactflow__edge-8d54b9db-3662-43af-8369-9a277e063f3bvalue-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204style",
- "type": "default",
- "source": "8d54b9db-3662-43af-8369-9a277e063f3b",
- "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "sourceHandle": "value",
- "targetHandle": "style"
- },
- {
- "id": "reactflow__edge-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204conditioning-84df8f00-ea7e-499f-ab86-d019ddea5393negative_conditioning",
- "type": "default",
- "source": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "target": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-faf965a4-7530-427b-b1f3-4ba6505c2a08conditioning-84df8f00-ea7e-499f-ab86-d019ddea5393positive_conditioning",
- "type": "default",
- "source": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "target": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-55705012-79b9-4aac-9f26-c0b10309785bnoise-84df8f00-ea7e-499f-ab86-d019ddea5393noise",
- "type": "default",
- "source": "55705012-79b9-4aac-9f26-c0b10309785b",
- "target": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-30d3289c-773c-4152-a9d2-bd8a99c8fd22unet-84df8f00-ea7e-499f-ab86-d019ddea5393unet",
- "type": "default",
- "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "target": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-06a30867-1e9d-461f-bd58-14a63cc997ddscheduler-84df8f00-ea7e-499f-ab86-d019ddea5393scheduler",
- "type": "default",
- "source": "06a30867-1e9d-461f-bd58-14a63cc997dd",
- "target": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "sourceHandle": "scheduler",
- "targetHandle": "scheduler"
- },
- {
- "id": "reactflow__edge-84df8f00-ea7e-499f-ab86-d019ddea5393latents-3d40eda5-ff7b-4dff-8d2e-4f44742faa1blatents",
- "type": "default",
- "source": "84df8f00-ea7e-499f-ab86-d019ddea5393",
- "target": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-62bdf243-d98f-4508-b6b5-c3af00ef49f0unet-3d40eda5-ff7b-4dff-8d2e-4f44742faa1bunet",
- "type": "default",
- "source": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "target": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-f0e06b70-9f53-44e3-8f5f-63d813b6b579conditioning-3d40eda5-ff7b-4dff-8d2e-4f44742faa1bpositive_conditioning",
- "type": "default",
- "source": "f0e06b70-9f53-44e3-8f5f-63d813b6b579",
- "target": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1conditioning-3d40eda5-ff7b-4dff-8d2e-4f44742faa1bnegative_conditioning",
- "type": "default",
- "source": "fbb2f1a0-2e68-411d-a955-60c3b8a6f2d1",
- "target": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-3d40eda5-ff7b-4dff-8d2e-4f44742faa1blatents-17eb4b88-bdd8-4984-affa-26586b146866latents",
- "type": "default",
- "source": "3d40eda5-ff7b-4dff-8d2e-4f44742faa1b",
- "target": "17eb4b88-bdd8-4984-affa-26586b146866",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-62bdf243-d98f-4508-b6b5-c3af00ef49f0vae-17eb4b88-bdd8-4984-affa-26586b146866vae",
- "type": "default",
- "source": "62bdf243-d98f-4508-b6b5-c3af00ef49f0",
- "target": "17eb4b88-bdd8-4984-affa-26586b146866",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- }
- ]
-}
\ No newline at end of file
diff --git a/docs/workflows/Text_to_Image.json b/docs/workflows/Text_to_Image.json
deleted file mode 100644
index 1e42df6e07a..00000000000
--- a/docs/workflows/Text_to_Image.json
+++ /dev/null
@@ -1,798 +0,0 @@
-{
- "name": "Text to Image - SD1.5",
- "author": "InvokeAI",
- "description": "Sample text to image workflow for Stable Diffusion 1.5/2",
- "version": "1.1.0",
- "contact": "invoke@invoke.ai",
- "tags": "text2image, SD1.5, SD2, default",
- "notes": "",
- "exposedFields": [
- {
- "nodeId": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "fieldName": "model"
- },
- {
- "nodeId": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "fieldName": "prompt"
- },
- {
- "nodeId": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "fieldName": "prompt"
- },
- {
- "nodeId": "55705012-79b9-4aac-9f26-c0b10309785b",
- "fieldName": "width"
- },
- {
- "nodeId": "55705012-79b9-4aac-9f26-c0b10309785b",
- "fieldName": "height"
- }
- ],
- "meta": {
- "category": "default",
- "version": "2.0.0"
- },
- "nodes": [
- {
- "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "type": "invocation",
- "data": {
- "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "type": "compel",
- "label": "Negative Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "7739aff6-26cb-4016-8897-5a1fb2305e4e",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Negative Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": ""
- },
- "clip": {
- "id": "48d23dce-a6ae-472a-9f8c-22a714ea5ce0",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "37cf3a9d-f6b7-4b64-8ff6-2558c5ecc447",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 259,
- "position": {
- "x": 1000,
- "y": 350
- }
- },
- {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "invocation",
- "data": {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "noise",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.1",
- "nodePack": "invokeai",
- "inputs": {
- "seed": {
- "id": "6431737c-918a-425d-a3b4-5d57e2f35d4d",
- "name": "seed",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "width": {
- "id": "38fc5b66-fe6e-47c8-bba9-daf58e454ed7",
- "name": "width",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "height": {
- "id": "16298330-e2bf-4872-a514-d6923df53cbb",
- "name": "height",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 512
- },
- "use_cpu": {
- "id": "c7c436d3-7a7a-4e76-91e4-c6deb271623c",
- "name": "use_cpu",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "noise": {
- "id": "50f650dc-0184-4e23-a927-0497a96fe954",
- "name": "noise",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "bb8a452b-133d-42d1-ae4a-3843d7e4109a",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "35cfaa12-3b8b-4b7a-a884-327ff3abddd9",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 388,
- "position": {
- "x": 600,
- "y": 325
- }
- },
- {
- "id": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "type": "invocation",
- "data": {
- "id": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "type": "main_model_loader",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "model": {
- "id": "993eabd2-40fd-44fe-bce7-5d0c7075ddab",
- "name": "model",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MainModelField"
- },
- "value": {
- "model_name": "stable-diffusion-v1-5",
- "base_model": "sd-1",
- "model_type": "main"
- }
- }
- },
- "outputs": {
- "unet": {
- "id": "5c18c9db-328d-46d0-8cb9-143391c410be",
- "name": "unet",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "clip": {
- "id": "6effcac0-ec2f-4bf5-a49e-a2c29cf921f4",
- "name": "clip",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- },
- "vae": {
- "id": "57683ba3-f5f5-4f58-b9a2-4b83dacad4a1",
- "name": "vae",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- }
- }
- },
- "width": 320,
- "height": 226,
- "position": {
- "x": 600,
- "y": 25
- }
- },
- {
- "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "type": "invocation",
- "data": {
- "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "type": "compel",
- "label": "Positive Compel Prompt",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "prompt": {
- "id": "7739aff6-26cb-4016-8897-5a1fb2305e4e",
- "name": "prompt",
- "fieldKind": "input",
- "label": "Positive Prompt",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "StringField"
- },
- "value": "Super cute tiger cub, national geographic award-winning photograph"
- },
- "clip": {
- "id": "48d23dce-a6ae-472a-9f8c-22a714ea5ce0",
- "name": "clip",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ClipField"
- }
- }
- },
- "outputs": {
- "conditioning": {
- "id": "37cf3a9d-f6b7-4b64-8ff6-2558c5ecc447",
- "name": "conditioning",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- }
- }
- },
- "width": 320,
- "height": 259,
- "position": {
- "x": 1000,
- "y": 25
- }
- },
- {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "invocation",
- "data": {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "rand_int",
- "label": "Random Seed",
- "isOpen": false,
- "notes": "",
- "isIntermediate": true,
- "useCache": false,
- "version": "1.0.0",
- "nodePack": "invokeai",
- "inputs": {
- "low": {
- "id": "3ec65a37-60ba-4b6c-a0b2-553dd7a84b84",
- "name": "low",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 0
- },
- "high": {
- "id": "085f853a-1a5f-494d-8bec-e4ba29a3f2d1",
- "name": "high",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 2147483647
- }
- },
- "outputs": {
- "value": {
- "id": "812ade4d-7699-4261-b9fc-a6c9d2ab55ee",
- "name": "value",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 32,
- "position": {
- "x": 600,
- "y": 275
- }
- },
- {
- "id": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "invocation",
- "data": {
- "id": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "denoise_latents",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": true,
- "useCache": true,
- "version": "1.5.0",
- "nodePack": "invokeai",
- "inputs": {
- "positive_conditioning": {
- "id": "90b7f4f8-ada7-4028-8100-d2e54f192052",
- "name": "positive_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "negative_conditioning": {
- "id": "9393779e-796c-4f64-b740-902a1177bf53",
- "name": "negative_conditioning",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ConditioningField"
- }
- },
- "noise": {
- "id": "8e17f1e5-4f98-40b1-b7f4-86aeeb4554c1",
- "name": "noise",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "steps": {
- "id": "9b63302d-6bd2-42c9-ac13-9b1afb51af88",
- "name": "steps",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- },
- "value": 50
- },
- "cfg_scale": {
- "id": "87dd04d3-870e-49e1-98bf-af003a810109",
- "name": "cfg_scale",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "FloatField"
- },
- "value": 7.5
- },
- "denoising_start": {
- "id": "f369d80f-4931-4740-9bcd-9f0620719fab",
- "name": "denoising_start",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "denoising_end": {
- "id": "747d10e5-6f02-445c-994c-0604d814de8c",
- "name": "denoising_end",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 1
- },
- "scheduler": {
- "id": "1de84a4e-3a24-4ec8-862b-16ce49633b9b",
- "name": "scheduler",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "SchedulerField"
- },
- "value": "unipc"
- },
- "unet": {
- "id": "ffa6fef4-3ce2-4bdb-9296-9a834849489b",
- "name": "unet",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "UNetField"
- }
- },
- "control": {
- "id": "077b64cb-34be-4fcc-83f2-e399807a02bd",
- "name": "control",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "ControlField"
- }
- },
- "ip_adapter": {
- "id": "1d6948f7-3a65-4a65-a20c-768b287251aa",
- "name": "ip_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "IPAdapterField"
- }
- },
- "t2i_adapter": {
- "id": "75e67b09-952f-4083-aaf4-6b804d690412",
- "name": "t2i_adapter",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": true,
- "name": "T2IAdapterField"
- }
- },
- "cfg_rescale_multiplier": {
- "id": "9101f0a6-5fe0-4826-b7b3-47e5d506826c",
- "name": "cfg_rescale_multiplier",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "FloatField"
- },
- "value": 0
- },
- "latents": {
- "id": "334d4ba3-5a99-4195-82c5-86fb3f4f7d43",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "denoise_mask": {
- "id": "0d3dbdbf-b014-4e95-8b18-ff2ff9cb0bfa",
- "name": "denoise_mask",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "DenoiseMaskField"
- }
- }
- },
- "outputs": {
- "latents": {
- "id": "70fa5bbc-0c38-41bb-861a-74d6d78d2f38",
- "name": "latents",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "width": {
- "id": "98ee0e6c-82aa-4e8f-8be5-dc5f00ee47f0",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "e8cb184a-5e1a-47c8-9695-4b8979564f5d",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 703,
- "position": {
- "x": 1400,
- "y": 25
- }
- },
- {
- "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "type": "invocation",
- "data": {
- "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "type": "l2i",
- "label": "",
- "isOpen": true,
- "notes": "",
- "isIntermediate": false,
- "useCache": true,
- "version": "1.2.0",
- "nodePack": "invokeai",
- "inputs": {
- "metadata": {
- "id": "ab375f12-0042-4410-9182-29e30db82c85",
- "name": "metadata",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "MetadataField"
- }
- },
- "latents": {
- "id": "3a7e7efd-bff5-47d7-9d48-615127afee78",
- "name": "latents",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "LatentsField"
- }
- },
- "vae": {
- "id": "a1f5f7a1-0795-4d58-b036-7820c0b0ef2b",
- "name": "vae",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "VaeField"
- }
- },
- "tiled": {
- "id": "da52059a-0cee-4668-942f-519aa794d739",
- "name": "tiled",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": false
- },
- "fp32": {
- "id": "c4841df3-b24e-4140-be3b-ccd454c2522c",
- "name": "fp32",
- "fieldKind": "input",
- "label": "",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "BooleanField"
- },
- "value": true
- }
- },
- "outputs": {
- "image": {
- "id": "72d667d0-cf85-459d-abf2-28bd8b823fe7",
- "name": "image",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "ImageField"
- }
- },
- "width": {
- "id": "c8c907d8-1066-49d1-b9a6-83bdcd53addc",
- "name": "width",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- },
- "height": {
- "id": "230f359c-b4ea-436c-b372-332d7dcdca85",
- "name": "height",
- "fieldKind": "output",
- "type": {
- "isCollection": false,
- "isCollectionOrScalar": false,
- "name": "IntegerField"
- }
- }
- }
- },
- "width": 320,
- "height": 266,
- "position": {
- "x": 1800,
- "y": 25
- }
- }
- ],
- "edges": [
- {
- "id": "reactflow__edge-ea94bc37-d995-4a83-aa99-4af42479f2f2value-55705012-79b9-4aac-9f26-c0b10309785bseed",
- "source": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "target": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "default",
- "sourceHandle": "value",
- "targetHandle": "seed"
- },
- {
- "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8clip-7d8bf987-284f-413a-b2fd-d825445a5d6cclip",
- "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "target": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8clip-93dc02a4-d05b-48ed-b99c-c9b616af3402clip",
- "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "target": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "type": "default",
- "sourceHandle": "clip",
- "targetHandle": "clip"
- },
- {
- "id": "reactflow__edge-55705012-79b9-4aac-9f26-c0b10309785bnoise-eea2702a-19fb-45b5-9d75-56b4211ec03cnoise",
- "source": "55705012-79b9-4aac-9f26-c0b10309785b",
- "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "default",
- "sourceHandle": "noise",
- "targetHandle": "noise"
- },
- {
- "id": "reactflow__edge-7d8bf987-284f-413a-b2fd-d825445a5d6cconditioning-eea2702a-19fb-45b5-9d75-56b4211ec03cpositive_conditioning",
- "source": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "positive_conditioning"
- },
- {
- "id": "reactflow__edge-93dc02a4-d05b-48ed-b99c-c9b616af3402conditioning-eea2702a-19fb-45b5-9d75-56b4211ec03cnegative_conditioning",
- "source": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "default",
- "sourceHandle": "conditioning",
- "targetHandle": "negative_conditioning"
- },
- {
- "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8unet-eea2702a-19fb-45b5-9d75-56b4211ec03cunet",
- "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "type": "default",
- "sourceHandle": "unet",
- "targetHandle": "unet"
- },
- {
- "id": "reactflow__edge-eea2702a-19fb-45b5-9d75-56b4211ec03clatents-58c957f5-0d01-41fc-a803-b2bbf0413d4flatents",
- "source": "eea2702a-19fb-45b5-9d75-56b4211ec03c",
- "target": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "type": "default",
- "sourceHandle": "latents",
- "targetHandle": "latents"
- },
- {
- "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8vae-58c957f5-0d01-41fc-a803-b2bbf0413d4fvae",
- "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "target": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "type": "default",
- "sourceHandle": "vae",
- "targetHandle": "vae"
- }
- ]
-}
diff --git a/flake.lock b/flake.lock
index 536c8259b1f..dedd56e74f3 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1690630721,
- "narHash": "sha256-Y04onHyBQT4Erfr2fc82dbJTfXGYrf4V0ysLUYnPOP8=",
+ "lastModified": 1727955264,
+ "narHash": "sha256-lrd+7mmb5NauRoMa8+J1jFKYVa+rc8aq2qc9+CxPDKc=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "d2b52322f35597c62abf56de91b0236746b2a03d",
+ "rev": "71cd616696bd199ef18de62524f3df3ffe8b9333",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 3ccc6658121..07af19e93bf 100644
--- a/flake.nix
+++ b/flake.nix
@@ -34,7 +34,7 @@
cudaPackages.cudnn
cudaPackages.cuda_nvrtc
cudatoolkit
- pkgconfig
+ pkg-config
libconfig
cmake
blas
@@ -66,7 +66,7 @@
black
# Frontend.
- yarn
+ pnpm_8
nodejs
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
diff --git a/installer/WinLongPathsEnabled.reg b/installer/WinLongPathsEnabled.reg
deleted file mode 100644
index 778782b2724..00000000000
Binary files a/installer/WinLongPathsEnabled.reg and /dev/null differ
diff --git a/installer/create_installer.sh b/installer/create_installer.sh
deleted file mode 100755
index a71b0d9c417..00000000000
--- a/installer/create_installer.sh
+++ /dev/null
@@ -1,133 +0,0 @@
-#!/bin/bash
-
-set -e
-
-BCYAN="\033[1;36m"
-BYELLOW="\033[1;33m"
-BGREEN="\033[1;32m"
-BRED="\033[1;31m"
-RED="\033[31m"
-RESET="\033[0m"
-
-function git_show {
- git show -s --format=oneline --abbrev-commit "$1" | cat
-}
-
-if [[ ! -z "${VIRTUAL_ENV}" ]]; then
- # we can't just call 'deactivate' because this function is not exported
- # to the environment of this script from the bash process that runs the script
- echo -e "${BRED}A virtual environment is activated. Please deactivate it before proceeding.${RESET}"
- exit -1
-fi
-
-cd "$(dirname "$0")"
-
-VERSION=$(
- cd ..
- python3 -c "from invokeai.version import __version__ as version; print(version)"
-)
-VERSION="v${VERSION}"
-
-if [[ ! -z ${CI} ]]; then
- echo
- echo -e "${BCYAN}CI environment detected${RESET}"
- echo
-else
- echo
- echo -e "${BYELLOW}This script must be run from the installer directory!${RESET}"
- echo "The current working directory is $(pwd)"
- read -p "If that looks right, press any key to proceed, or CTRL-C to exit..."
- echo
-fi
-
-echo -e "${BGREEN}HEAD${RESET}:"
-git_show HEAD
-echo
-
-# ---------------------- FRONTEND ----------------------
-
-pushd ../invokeai/frontend/web >/dev/null
-echo "Installing frontend dependencies..."
-echo
-pnpm i --frozen-lockfile
-echo
-if [[ ! -z ${CI} ]]; then
- echo "Building frontend without checks..."
- # In CI, we have already done the frontend checks and can just build
- pnpm vite build
-else
- echo "Running checks and building frontend..."
- # This runs all the frontend checks and builds
- pnpm build
-fi
-echo
-popd
-
-# ---------------------- BACKEND ----------------------
-
-echo
-echo "Building wheel..."
-echo
-
-# install the 'build' package in the user site packages, if needed
-# could be improved by using a temporary venv, but it's tiny and harmless
-if [[ $(python3 -c 'from importlib.util import find_spec; print(find_spec("build") is None)') == "True" ]]; then
- pip install --user build
-fi
-
-rm -rf ../build
-
-python3 -m build --outdir dist/ ../.
-
-# ----------------------
-
-echo
-echo "Building installer zip files for InvokeAI ${VERSION}..."
-echo
-
-# get rid of any old ones
-rm -f *.zip
-rm -rf InvokeAI-Installer
-
-# copy content
-mkdir InvokeAI-Installer
-for f in templates *.txt *.reg; do
- cp -r ${f} InvokeAI-Installer/
-done
-mkdir InvokeAI-Installer/lib
-cp lib/*.py InvokeAI-Installer/lib
-
-# Install scripts
-# Mac/Linux
-cp install.sh.in InvokeAI-Installer/install.sh
-chmod a+x InvokeAI-Installer/install.sh
-
-# Windows
-cp install.bat.in InvokeAI-Installer/install.bat
-cp WinLongPathsEnabled.reg InvokeAI-Installer/
-
-FILENAME=InvokeAI-installer-$VERSION.zip
-
-# Zip everything up
-zip -r ${FILENAME} InvokeAI-Installer
-
-echo
-echo -e "${BGREEN}Built installer: ./${FILENAME}${RESET}"
-echo -e "${BGREEN}Built PyPi distribution: ./dist${RESET}"
-
-# clean up, but only if we are not in a github action
-if [[ -z ${CI} ]]; then
- echo
- echo "Cleaning up intermediate build files..."
- rm -rf InvokeAI-Installer tmp ../invokeai/frontend/web/dist/
-fi
-
-if [[ ! -z ${CI} ]]; then
- echo
- echo "Setting GitHub action outputs..."
- echo "INSTALLER_FILENAME=${FILENAME}" >>$GITHUB_OUTPUT
- echo "INSTALLER_PATH=installer/${FILENAME}" >>$GITHUB_OUTPUT
- echo "DIST_PATH=installer/dist/" >>$GITHUB_OUTPUT
-fi
-
-exit 0
diff --git a/installer/install.bat.in b/installer/install.bat.in
deleted file mode 100644
index b06aa97c98b..00000000000
--- a/installer/install.bat.in
+++ /dev/null
@@ -1,128 +0,0 @@
-@echo off
-setlocal EnableExtensions EnableDelayedExpansion
-
-@rem This script requires the user to install Python 3.10 or higher. All other
-@rem requirements are downloaded as needed.
-
-@rem change to the script's directory
-PUSHD "%~dp0"
-
-set "no_cache_dir=--no-cache-dir"
-if "%1" == "use-cache" (
- set "no_cache_dir="
-)
-
-@rem Config
-@rem The version in the next line is replaced by an up to date release number
-@rem when create_installer.sh is run. Change the release number there.
-set INSTRUCTIONS=https://invoke-ai.github.io/InvokeAI/installation/INSTALL_AUTOMATED/
-set TROUBLESHOOTING=https://invoke-ai.github.io/InvokeAI/help/FAQ/
-set PYTHON_URL=https://www.python.org/downloads/windows/
-set MINIMUM_PYTHON_VERSION=3.10.0
-set PYTHON_URL=https://www.python.org/downloads/release/python-3109/
-
-set err_msg=An error has occurred and the script could not continue.
-
-@rem --------------------------- Intro -------------------------------
-echo This script will install InvokeAI and its dependencies.
-echo.
-echo BEFORE YOU START PLEASE MAKE SURE TO DO THE FOLLOWING
-echo 1. Install python 3.10 or 3.11. Python version 3.9 is no longer supported.
-echo 2. Double-click on the file WinLongPathsEnabled.reg in order to
-echo enable long path support on your system.
-echo 3. Install the Visual C++ core libraries.
-echo Please download and install the libraries from:
-echo https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist?view=msvc-170
-echo.
-echo See %INSTRUCTIONS% for more details.
-echo.
-echo FOR THE BEST USER EXPERIENCE WE SUGGEST MAXIMIZING THIS WINDOW NOW.
-pause
-
-@rem ---------------------------- check Python version ---------------
-echo ***** Checking and Updating Python *****
-
-call python --version >.tmp1 2>.tmp2
-if %errorlevel% == 1 (
- set err_msg=Please install Python 3.10-11. See %INSTRUCTIONS% for details.
- goto err_exit
-)
-
-for /f "tokens=2" %%i in (.tmp1) do set python_version=%%i
-if "%python_version%" == "" (
- set err_msg=No python was detected on your system. Please install Python version %MINIMUM_PYTHON_VERSION% or higher. We recommend Python 3.10.12 from %PYTHON_URL%
- goto err_exit
-)
-
-call :compareVersions %MINIMUM_PYTHON_VERSION% %python_version%
-if %errorlevel% == 1 (
- set err_msg=Your version of Python is too low. You need at least %MINIMUM_PYTHON_VERSION% but you have %python_version%. We recommend Python 3.10.12 from %PYTHON_URL%
- goto err_exit
-)
-
-@rem Cleanup
-del /q .tmp1 .tmp2
-
-@rem -------------- Install and Configure ---------------
-
-call python .\lib\main.py
-pause
-exit /b
-
-@rem ------------------------ Subroutines ---------------
-@rem routine to do comparison of semantic version numbers
-@rem found at https://stackoverflow.com/questions/15807762/compare-version-numbers-in-batch-file
-:compareVersions
-::
-:: Compares two version numbers and returns the result in the ERRORLEVEL
-::
-:: Returns 1 if version1 > version2
-:: 0 if version1 = version2
-:: -1 if version1 < version2
-::
-:: The nodes must be delimited by . or , or -
-::
-:: Nodes are normally strictly numeric, without a 0 prefix. A letter suffix
-:: is treated as a separate node
-::
-setlocal enableDelayedExpansion
-set "v1=%~1"
-set "v2=%~2"
-call :divideLetters v1
-call :divideLetters v2
-:loop
-call :parseNode "%v1%" n1 v1
-call :parseNode "%v2%" n2 v2
-if %n1% gtr %n2% exit /b 1
-if %n1% lss %n2% exit /b -1
-if not defined v1 if not defined v2 exit /b 0
-if not defined v1 exit /b -1
-if not defined v2 exit /b 1
-goto :loop
-
-
-:parseNode version nodeVar remainderVar
-for /f "tokens=1* delims=.,-" %%A in ("%~1") do (
- set "%~2=%%A"
- set "%~3=%%B"
-)
-exit /b
-
-
-:divideLetters versionVar
-for %%C in (a b c d e f g h i j k l m n o p q r s t u v w x y z) do set "%~1=!%~1:%%C=.%%C!"
-exit /b
-
-:err_exit
-echo %err_msg%
-echo The installer will exit now.
-pause
-exit /b
-
-pause
-
-:Trim
-SetLocal EnableDelayedExpansion
-set Params=%*
-for /f "tokens=1*" %%a in ("!Params!") do EndLocal & set %1=%%b
-exit /b
diff --git a/installer/install.sh.in b/installer/install.sh.in
deleted file mode 100755
index 9cf41192bf1..00000000000
--- a/installer/install.sh.in
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/bin/bash
-
-# make sure we are not already in a venv
-# (don't need to check status)
-deactivate >/dev/null 2>&1
-scriptdir=$(dirname "$0")
-cd $scriptdir
-
-function version { echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }'; }
-
-MINIMUM_PYTHON_VERSION=3.10.0
-MAXIMUM_PYTHON_VERSION=3.11.100
-PYTHON=""
-for candidate in python3.11 python3.10 python3 python ; do
- if ppath=`which $candidate`; then
- # when using `pyenv`, the executable for an inactive Python version will exist but will not be operational
- # we check that this found executable can actually run
- if [ $($candidate --version &>/dev/null; echo ${PIPESTATUS}) -gt 0 ]; then continue; fi
-
- python_version=$($ppath -V | awk '{ print $2 }')
- if [ $(version $python_version) -ge $(version "$MINIMUM_PYTHON_VERSION") ]; then
- if [ $(version $python_version) -le $(version "$MAXIMUM_PYTHON_VERSION") ]; then
- PYTHON=$ppath
- break
- fi
- fi
- fi
-done
-
-if [ -z "$PYTHON" ]; then
- echo "A suitable Python interpreter could not be found"
- echo "Please install Python $MINIMUM_PYTHON_VERSION or higher (maximum $MAXIMUM_PYTHON_VERSION) before running this script. See instructions at $INSTRUCTIONS for help."
- echo "For the best user experience we suggest enlarging or maximizing this window now."
- read -p "Press any key to exit"
- exit -1
-fi
-
-exec $PYTHON ./lib/main.py ${@}
-read -p "Press any key to exit"
diff --git a/installer/lib/installer.py b/installer/lib/installer.py
deleted file mode 100644
index 11823b413e0..00000000000
--- a/installer/lib/installer.py
+++ /dev/null
@@ -1,435 +0,0 @@
-# Copyright (c) 2023 Eugene Brodsky (https://github.com/ebr)
-"""
-InvokeAI installer script
-"""
-
-import locale
-import os
-import platform
-import re
-import shutil
-import subprocess
-import sys
-import venv
-from pathlib import Path
-from tempfile import TemporaryDirectory
-from typing import Optional, Tuple
-
-SUPPORTED_PYTHON = ">=3.10.0,<=3.11.100"
-INSTALLER_REQS = ["rich", "semver", "requests", "plumbum", "prompt-toolkit"]
-BOOTSTRAP_VENV_PREFIX = "invokeai-installer-tmp"
-DOCS_URL = "https://invoke-ai.github.io/InvokeAI/"
-DISCORD_URL = "https://discord.gg/ZmtBAhwWhy"
-
-OS = platform.uname().system
-ARCH = platform.uname().machine
-VERSION = "latest"
-
-
-def get_version_from_wheel_filename(wheel_filename: str) -> str:
- match = re.search(r"-(\d+\.\d+\.\d+)", wheel_filename)
- if match:
- version = match.group(1)
- return version
- else:
- raise ValueError(f"Could not extract version from wheel filename: {wheel_filename}")
-
-
-class Installer:
- """
- Deploys an InvokeAI installation into a given path
- """
-
- reqs: list[str] = INSTALLER_REQS
-
- def __init__(self) -> None:
- if os.getenv("VIRTUAL_ENV") is not None:
- print("A virtual environment is already activated. Please 'deactivate' before installation.")
- sys.exit(-1)
- self.bootstrap()
- self.available_releases = get_github_releases()
-
- def mktemp_venv(self) -> TemporaryDirectory[str]:
- """
- Creates a temporary virtual environment for the installer itself
-
- :return: path to the created virtual environment directory
- :rtype: TemporaryDirectory
- """
-
- # Cleaning up temporary directories on Windows results in a race condition
- # and a stack trace.
- # `ignore_cleanup_errors` was only added in Python 3.10
- if OS == "Windows" and int(platform.python_version_tuple()[1]) >= 10:
- venv_dir = TemporaryDirectory(prefix=BOOTSTRAP_VENV_PREFIX, ignore_cleanup_errors=True)
- else:
- venv_dir = TemporaryDirectory(prefix=BOOTSTRAP_VENV_PREFIX)
-
- venv.create(venv_dir.name, with_pip=True)
- self.venv_dir = venv_dir
- set_sys_path(Path(venv_dir.name))
-
- return venv_dir
-
- def bootstrap(self, verbose: bool = False) -> TemporaryDirectory[str] | None:
- """
- Bootstrap the installer venv with packages required at install time
- """
-
- print("Initializing the installer. This may take a minute - please wait...")
-
- venv_dir = self.mktemp_venv()
- pip = get_pip_from_venv(Path(venv_dir.name))
-
- cmd = [pip, "install", "--require-virtualenv", "--use-pep517"]
- cmd.extend(self.reqs)
-
- try:
- # upgrade pip to the latest version to avoid a confusing message
- res = upgrade_pip(Path(venv_dir.name))
- if verbose:
- print(res)
-
- # run the install prerequisites installation
- res = subprocess.check_output(cmd).decode()
-
- if verbose:
- print(res)
-
- return venv_dir
- except subprocess.CalledProcessError as e:
- print(e)
-
- def app_venv(self, venv_parent: Path) -> Path:
- """
- Create a virtualenv for the InvokeAI installation
- """
-
- venv_dir = venv_parent / ".venv"
-
- # Prefer to copy python executables
- # so that updates to system python don't break InvokeAI
- try:
- venv.create(venv_dir, with_pip=True)
- # If installing over an existing environment previously created with symlinks,
- # the executables will fail to copy. Keep symlinks in that case
- except shutil.SameFileError:
- venv.create(venv_dir, with_pip=True, symlinks=True)
-
- return venv_dir
-
- def install(
- self,
- root: str = "~/invokeai",
- yes_to_all: bool = False,
- find_links: Optional[str] = None,
- wheel: Optional[Path] = None,
- ) -> None:
- """Install the InvokeAI application into the given runtime path
-
- Args:
- root: Destination path for the installation
- yes_to_all: Accept defaults to all questions
- find_links: A local directory to search for requirement wheels before going to remote indexes
- wheel: A wheel file to install
- """
-
- import messages
-
- if wheel:
- messages.installing_from_wheel(wheel.name)
- version = get_version_from_wheel_filename(wheel.name)
- else:
- messages.welcome(self.available_releases)
- version = messages.choose_version(self.available_releases)
-
- auto_dest = Path(os.environ.get("INVOKEAI_ROOT", root)).expanduser().resolve()
- destination = auto_dest if yes_to_all else messages.dest_path(root)
- if destination is None:
- print("Could not find or create the destination directory. Installation cancelled.")
- sys.exit(0)
-
- # create the venv for the app
- self.venv = self.app_venv(venv_parent=destination)
-
- self.instance = InvokeAiInstance(runtime=destination, venv=self.venv, version=version)
-
- # install dependencies and the InvokeAI application
- (extra_index_url, optional_modules) = get_torch_source() if not yes_to_all else (None, None)
- self.instance.install(extra_index_url, optional_modules, find_links, wheel)
-
- # install the launch/update scripts into the runtime directory
- self.instance.install_user_scripts()
-
- message = f"""
-*** Installation Successful ***
-
-To start the application, run:
- {destination}/invoke.{"bat" if sys.platform == "win32" else "sh"}
-
-For more information, troubleshooting and support, visit our docs at:
- {DOCS_URL}
-
-Join the community on Discord:
- {DISCORD_URL}
-"""
- print(message)
-
-
-class InvokeAiInstance:
- """
- Manages an installed instance of InvokeAI, comprising a virtual environment and a runtime directory.
- The virtual environment *may* reside within the runtime directory.
- A single runtime directory *may* be shared by multiple virtual environments, though this isn't currently tested or supported.
- """
-
- def __init__(self, runtime: Path, venv: Path, version: str = "stable") -> None:
- self.runtime = runtime
- self.venv = venv
- self.pip = get_pip_from_venv(venv)
- self.version = version
-
- set_sys_path(venv)
- os.environ["INVOKEAI_ROOT"] = str(self.runtime.expanduser().resolve())
- os.environ["VIRTUAL_ENV"] = str(self.venv.expanduser().resolve())
- upgrade_pip(venv)
-
- def get(self) -> tuple[Path, Path]:
- """
- Get the location of the virtualenv directory for this installation
-
- :return: Paths of the runtime and the venv directory
- :rtype: tuple[Path, Path]
- """
-
- return (self.runtime, self.venv)
-
- def install(
- self,
- extra_index_url: Optional[str] = None,
- optional_modules: Optional[str] = None,
- find_links: Optional[str] = None,
- wheel: Optional[Path] = None,
- ):
- """Install the package from PyPi or a wheel, if provided.
-
- Args:
- extra_index_url: the "--extra-index-url ..." line for pip to look in extra indexes.
- optional_modules: optional modules to install using "[module1,module2]" format.
- find_links: path to a directory containing wheels to be searched prior to going to the internet
- wheel: a wheel file to install
- """
-
- import messages
-
- # not currently used, but may be useful for "install most recent version" option
- if self.version == "prerelease":
- version = None
- pre_flag = "--pre"
- elif self.version == "stable":
- version = None
- pre_flag = None
- else:
- version = self.version
- pre_flag = None
-
- src = "invokeai"
- if optional_modules:
- src += optional_modules
- if version:
- src += f"=={version}"
-
- messages.simple_banner("Installing the InvokeAI Application :art:")
-
- from plumbum import FG, ProcessExecutionError, local
-
- pip = local[self.pip]
-
- pipeline = pip[
- "install",
- "--require-virtualenv",
- "--force-reinstall",
- "--use-pep517",
- str(src) if not wheel else str(wheel),
- "--find-links" if find_links is not None else None,
- find_links,
- "--extra-index-url" if extra_index_url is not None else None,
- extra_index_url,
- pre_flag if not wheel else None, # Ignore the flag if we are installing a wheel
- ]
-
- try:
- _ = pipeline & FG
- except ProcessExecutionError as e:
- print(f"Error: {e}")
- print(
- "Could not install InvokeAI. Please try downloading the latest version of the installer and install again."
- )
- sys.exit(1)
-
- def install_user_scripts(self):
- """
- Copy the launch and update scripts to the runtime dir
- """
-
- ext = "bat" if OS == "Windows" else "sh"
-
- scripts = ["invoke"]
-
- for script in scripts:
- src = Path(__file__).parent / ".." / "templates" / f"{script}.{ext}.in"
- dest = self.runtime / f"{script}.{ext}"
- shutil.copy(src, dest)
- os.chmod(dest, 0o0755)
-
- def update(self):
- pass
-
- def remove(self):
- pass
-
-
-### Utility functions ###
-
-
-def get_pip_from_venv(venv_path: Path) -> str:
- """
- Given a path to a virtual environment, get the absolute path to the `pip` executable
- in a cross-platform fashion. Does not validate that the pip executable
- actually exists in the virtualenv.
-
- :param venv_path: Path to the virtual environment
- :type venv_path: Path
- :return: Absolute path to the pip executable
- :rtype: str
- """
-
- pip = "Scripts\\pip.exe" if OS == "Windows" else "bin/pip"
- return str(venv_path.expanduser().resolve() / pip)
-
-
-def upgrade_pip(venv_path: Path) -> str | None:
- """
- Upgrade the pip executable in the given virtual environment
- """
-
- python = "Scripts\\python.exe" if OS == "Windows" else "bin/python"
- python = str(venv_path.expanduser().resolve() / python)
-
- try:
- result = subprocess.check_output([python, "-m", "pip", "install", "--upgrade", "pip"]).decode(
- encoding=locale.getpreferredencoding()
- )
- except subprocess.CalledProcessError as e:
- print(e)
- result = None
-
- return result
-
-
-def set_sys_path(venv_path: Path) -> None:
- """
- Given a path to a virtual environment, set the sys.path, in a cross-platform fashion,
- such that packages from the given venv may be imported in the current process.
- Ensure that the packages from system environment are not visible (emulate
- the virtual env 'activate' script) - this doesn't work on Windows yet.
-
- :param venv_path: Path to the virtual environment
- :type venv_path: Path
- """
-
- # filter out any paths in sys.path that may be system- or user-wide
- # but leave the temporary bootstrap virtualenv as it contains packages we
- # temporarily need at install time
- sys.path = list(filter(lambda p: not p.endswith("-packages") or p.find(BOOTSTRAP_VENV_PREFIX) != -1, sys.path))
-
- # determine site-packages/lib directory location for the venv
- lib = "Lib" if OS == "Windows" else f"lib/python{sys.version_info.major}.{sys.version_info.minor}"
-
- # add the site-packages location to the venv
- sys.path.append(str(Path(venv_path, lib, "site-packages").expanduser().resolve()))
-
-
-def get_github_releases() -> tuple[list[str], list[str]] | None:
- """
- Query Github for published (pre-)release versions.
- Return a tuple where the first element is a list of stable releases and the second element is a list of pre-releases.
- Return None if the query fails for any reason.
- """
-
- import requests
-
- ## get latest releases using github api
- url = "https://api.github.com/repos/invoke-ai/InvokeAI/releases"
- releases: list[str] = []
- pre_releases: list[str] = []
- try:
- res = requests.get(url)
- res.raise_for_status()
- tag_info = res.json()
- for tag in tag_info:
- if not tag["prerelease"]:
- releases.append(tag["tag_name"].lstrip("v"))
- else:
- pre_releases.append(tag["tag_name"].lstrip("v"))
- except requests.HTTPError as e:
- print(f"Error: {e}")
- print("Could not fetch version information from GitHub. Please check your network connection and try again.")
- return
- except Exception as e:
- print(f"Error: {e}")
- print("An unexpected error occurred while trying to fetch version information from GitHub. Please try again.")
- return
-
- releases.sort(reverse=True)
- pre_releases.sort(reverse=True)
-
- return releases, pre_releases
-
-
-def get_torch_source() -> Tuple[str | None, str | None]:
- """
- Determine the extra index URL for pip to use for torch installation.
- This depends on the OS and the graphics accelerator in use.
- This is only applicable to Windows and Linux, since PyTorch does not
- offer accelerated builds for macOS.
-
- Prefer CUDA-enabled wheels if the user wasn't sure of their GPU, as it will fallback to CPU if possible.
-
- A NoneType return means just go to PyPi.
-
- :return: tuple consisting of (extra index url or None, optional modules to load or None)
- :rtype: list
- """
-
- from messages import select_gpu
-
- # device can be one of: "cuda", "rocm", "cpu", "cuda_and_dml, autodetect"
- device = select_gpu()
-
- # The correct extra index URLs for torch are inconsistent, see https://pytorch.org/get-started/locally/#start-locally
-
- url = None
- optional_modules: str | None = None
- if OS == "Linux":
- if device.value == "rocm":
- url = "https://download.pytorch.org/whl/rocm5.6"
- elif device.value == "cpu":
- url = "https://download.pytorch.org/whl/cpu"
- elif device.value == "cuda":
- # CUDA uses the default PyPi index
- optional_modules = "[xformers,onnx-cuda]"
- elif OS == "Windows":
- if device.value == "cuda":
- url = "https://download.pytorch.org/whl/cu121"
- optional_modules = "[xformers,onnx-cuda]"
- elif device.value == "cpu":
- # CPU uses the default PyPi index, no optional modules
- pass
- elif OS == "Darwin":
- # macOS uses the default PyPi index, no optional modules
- pass
-
- # Fall back to defaults
-
- return (url, optional_modules)
diff --git a/installer/lib/main.py b/installer/lib/main.py
deleted file mode 100644
index be9dc18f966..00000000000
--- a/installer/lib/main.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""
-InvokeAI Installer
-"""
-
-import argparse
-import os
-from pathlib import Path
-
-from installer import Installer
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
-
- parser.add_argument(
- "-r",
- "--root",
- dest="root",
- type=str,
- help="Destination path for installation",
- default=os.environ.get("INVOKEAI_ROOT") or "~/invokeai",
- )
- parser.add_argument(
- "-y",
- "--yes",
- "--yes-to-all",
- dest="yes_to_all",
- action="store_true",
- help="Assume default answers to all questions",
- default=False,
- )
-
- parser.add_argument(
- "--find-links",
- dest="find_links",
- help="Specifies a directory of local wheel files to be searched prior to searching the online repositories.",
- type=Path,
- default=None,
- )
-
- parser.add_argument(
- "--wheel",
- dest="wheel",
- help="Specifies a wheel for the InvokeAI package. Used for troubleshooting or testing prereleases.",
- type=Path,
- default=None,
- )
-
- args = parser.parse_args()
-
- inst = Installer()
-
- try:
- inst.install(**args.__dict__)
- except KeyboardInterrupt:
- print("\n")
- print("Ctrl-C pressed. Aborting.")
- print("Come back soon!")
diff --git a/installer/lib/messages.py b/installer/lib/messages.py
deleted file mode 100644
index dcd65a98137..00000000000
--- a/installer/lib/messages.py
+++ /dev/null
@@ -1,338 +0,0 @@
-# Copyright (c) 2023 Eugene Brodsky (https://github.com/ebr)
-"""
-Installer user interaction
-"""
-
-import os
-import platform
-from enum import Enum
-from pathlib import Path
-from typing import Optional
-
-from prompt_toolkit import prompt
-from prompt_toolkit.completion import FuzzyWordCompleter, PathCompleter
-from prompt_toolkit.validation import Validator
-from rich import box, print
-from rich.console import Console, Group, group
-from rich.panel import Panel
-from rich.prompt import Confirm
-from rich.style import Style
-from rich.syntax import Syntax
-from rich.text import Text
-
-OS = platform.uname().system
-ARCH = platform.uname().machine
-
-if OS == "Windows":
- # Windows terminals look better without a background colour
- console = Console(style=Style(color="grey74"))
-else:
- console = Console(style=Style(color="grey74", bgcolor="grey19"))
-
-
-def welcome(available_releases: tuple[list[str], list[str]] | None = None) -> None:
- @group()
- def text():
- if (platform_specific := _platform_specific_help()) is not None:
- yield platform_specific
- yield ""
- yield Text.from_markup(
- "Some of the installation steps take a long time to run. Please be patient. If the script appears to hang for more than 10 minutes, please interrupt with [i]Control-C[/] and retry.",
- justify="center",
- )
- if available_releases is not None:
- latest_stable = available_releases[0][0]
- last_pre = available_releases[1][0]
- yield ""
- yield Text.from_markup(
- f"[red3]🠶[/] Latest stable release (recommended): [b bright_white]{latest_stable}", justify="center"
- )
- yield Text.from_markup(
- f"[red3]🠶[/] Last published pre-release version: [b bright_white]{last_pre}", justify="center"
- )
-
- console.rule()
- print(
- Panel(
- title="[bold wheat1]Welcome to the InvokeAI Installer",
- renderable=text(),
- box=box.DOUBLE,
- expand=True,
- padding=(1, 2),
- style=Style(bgcolor="grey23", color="orange1"),
- subtitle=f"[bold grey39]{OS}-{ARCH}",
- )
- )
- console.line()
-
-
-def installing_from_wheel(wheel_filename: str) -> None:
- """Display a message about installing from a wheel"""
-
- @group()
- def text():
- yield Text.from_markup(f"You are installing from a wheel file: [bold]{wheel_filename}\n")
- yield Text.from_markup(
- "[bold orange3]If you are not sure why you are doing this, you should cancel and install InvokeAI normally."
- )
-
- console.print(
- Panel(
- title="Installing from Wheel",
- renderable=text(),
- box=box.DOUBLE,
- expand=True,
- padding=(1, 2),
- )
- )
-
- should_proceed = Confirm.ask("Do you want to proceed?")
-
- if not should_proceed:
- console.print("Installation cancelled.")
- exit()
-
-
-def choose_version(available_releases: tuple[list[str], list[str]] | None = None) -> str:
- """
- Prompt the user to choose an Invoke version to install
- """
-
- # short circuit if we couldn't get a version list
- # still try to install the latest stable version
- if available_releases is None:
- return "stable"
-
- console.print(":grey_question: [orange3]Please choose an Invoke version to install.")
-
- choices = available_releases[0] + available_releases[1]
-
- response = prompt(
- message=f" to install the recommended release ({choices[0]}). or type to pick a version: ",
- complete_while_typing=True,
- completer=FuzzyWordCompleter(choices),
- )
- console.print(f" Version {choices[0] if response == '' else response} will be installed.")
-
- console.line()
-
- return "stable" if response == "" else response
-
-
-def confirm_install(dest: Path) -> bool:
- if dest.exists():
- print(f":stop_sign: Directory {dest} already exists!")
- print(" Is this location correct?")
- default = False
- else:
- print(f":file_folder: InvokeAI will be installed in {dest}")
- default = True
-
- dest_confirmed = Confirm.ask(" Please confirm:", default=default)
-
- console.line()
-
- return dest_confirmed
-
-
-def dest_path(dest: Optional[str | Path] = None) -> Path | None:
- """
- Prompt the user for the destination path and create the path
-
- :param dest: a filesystem path, defaults to None
- :type dest: str, optional
- :return: absolute path to the created installation directory
- :rtype: Path
- """
-
- if dest is not None:
- dest = Path(dest).expanduser().resolve()
- else:
- dest = Path.cwd().expanduser().resolve()
- prev_dest = init_path = dest
- dest_confirmed = False
-
- while not dest_confirmed:
- browse_start = (dest or Path.cwd()).expanduser().resolve()
-
- path_completer = PathCompleter(
- only_directories=True,
- expanduser=True,
- get_paths=lambda: [str(browse_start)], # noqa: B023
- # get_paths=lambda: [".."].extend(list(browse_start.iterdir()))
- )
-
- console.line()
-
- console.print(f":grey_question: [orange3]Please select the install destination:[/] \\[{browse_start}]: ")
- selected = prompt(
- ">>> ",
- complete_in_thread=True,
- completer=path_completer,
- default=str(browse_start) + os.sep,
- vi_mode=True,
- complete_while_typing=True,
- # Test that this is not needed on Windows
- # complete_style=CompleteStyle.READLINE_LIKE,
- )
- prev_dest = dest
- dest = Path(selected)
-
- console.line()
-
- dest_confirmed = confirm_install(dest.expanduser().resolve())
-
- if not dest_confirmed:
- dest = prev_dest
-
- dest = dest.expanduser().resolve()
-
- try:
- dest.mkdir(exist_ok=True, parents=True)
- return dest
- except PermissionError:
- console.print(
- f"Failed to create directory {dest} due to insufficient permissions",
- style=Style(color="red"),
- highlight=True,
- )
- except OSError:
- console.print_exception()
-
- if Confirm.ask("Would you like to try again?"):
- dest_path(init_path)
- else:
- console.rule("Goodbye!")
-
-
-class GpuType(Enum):
- CUDA = "cuda"
- ROCM = "rocm"
- CPU = "cpu"
-
-
-def select_gpu() -> GpuType:
- """
- Prompt the user to select the GPU driver
- """
-
- if ARCH == "arm64" and OS != "Darwin":
- print(f"Only CPU acceleration is available on {ARCH} architecture. Proceeding with that.")
- return GpuType.CPU
-
- nvidia = (
- "an [gold1 b]NVIDIA[/] GPU (using CUDA™)",
- GpuType.CUDA,
- )
- amd = (
- "an [gold1 b]AMD[/] GPU (using ROCm™)",
- GpuType.ROCM,
- )
- cpu = (
- "Do not install any GPU support, use CPU for generation (slow)",
- GpuType.CPU,
- )
-
- options = []
- if OS == "Windows":
- options = [nvidia, cpu]
- if OS == "Linux":
- options = [nvidia, amd, cpu]
- elif OS == "Darwin":
- options = [cpu]
-
- if len(options) == 1:
- print(f'Your platform [gold1]{OS}-{ARCH}[/] only supports the "{options[0][1]}" driver. Proceeding with that.')
- return options[0][1]
-
- options = {str(i): opt for i, opt in enumerate(options, 1)}
-
- console.rule(":space_invader: GPU (Graphics Card) selection :space_invader:")
- console.print(
- Panel(
- Group(
- "\n".join(
- [
- f"Detected the [gold1]{OS}-{ARCH}[/] platform",
- "",
- "See [deep_sky_blue1]https://invoke-ai.github.io/InvokeAI/#system[/] to ensure your system meets the minimum requirements.",
- "",
- "[red3]🠶[/] [b]Your GPU drivers must be correctly installed before using InvokeAI![/] [red3]🠴[/]",
- ]
- ),
- "",
- "Please select the type of GPU installed in your computer.",
- Panel(
- "\n".join([f"[dark_goldenrod b i]{i}[/] [dark_red]🢒[/]{opt[0]}" for (i, opt) in options.items()]),
- box=box.MINIMAL,
- ),
- ),
- box=box.MINIMAL,
- padding=(1, 1),
- )
- )
- choice = prompt(
- "Please make your selection: ",
- validator=Validator.from_callable(
- lambda n: n in options.keys(), error_message="Please select one the above options"
- ),
- )
-
- return options[choice][1]
-
-
-def simple_banner(message: str) -> None:
- """
- A simple banner with a message, defined here for styling consistency
-
- :param message: The message to display
- :type message: str
- """
-
- console.rule(message)
-
-
-# TODO this does not yet work correctly
-def windows_long_paths_registry() -> None:
- """
- Display a message about applying the Windows long paths registry fix
- """
-
- with open(str(Path(__file__).parent / "WinLongPathsEnabled.reg"), "r", encoding="utf-16le") as code:
- syntax = Syntax(code.read(), line_numbers=True, lexer="regedit")
-
- console.print(
- Panel(
- Group(
- "\n".join(
- [
- "We will now apply a registry fix to enable long paths on Windows. InvokeAI needs this to function correctly. We are asking your permission to modify the Windows Registry on your behalf.",
- "",
- "This is the change that will be applied:",
- str(syntax),
- ]
- )
- ),
- title="Windows Long Paths registry fix",
- box=box.HORIZONTALS,
- padding=(1, 1),
- )
- )
-
-
-def _platform_specific_help() -> Text | None:
- if OS == "Darwin":
- text = Text.from_markup(
- """[b wheat1]macOS Users![/]\n\nPlease be sure you have the [b wheat1]Xcode command-line tools[/] installed before continuing.\nIf not, cancel with [i]Control-C[/] and follow the Xcode install instructions at [deep_sky_blue1]https://www.freecodecamp.org/news/install-xcode-command-line-tools/[/]."""
- )
- elif OS == "Windows":
- text = Text.from_markup(
- """[b wheat1]Windows Users![/]\n\nBefore you start, please do the following:
- 1. Double-click on the file [b wheat1]WinLongPathsEnabled.reg[/] in order to
- enable long path support on your system.
- 2. Make sure you have the [b wheat1]Visual C++ core libraries[/] installed. If not, install from
- [deep_sky_blue1]https://learn.microsoft.com/en-US/cpp/windows/latest-supported-vc-redist?view=msvc-170[/]"""
- )
- else:
- return
- return text
diff --git a/installer/readme.txt b/installer/readme.txt
deleted file mode 100644
index ef040c3913c..00000000000
--- a/installer/readme.txt
+++ /dev/null
@@ -1,52 +0,0 @@
-InvokeAI
-
-Project homepage: https://github.com/invoke-ai/InvokeAI
-
-Preparations:
-
- You will need to install Python 3.10 or higher for this installer
- to work. Instructions are given here:
- https://invoke-ai.github.io/InvokeAI/installation/INSTALL_AUTOMATED/
-
- Before you start the installer, please open up your system's command
- line window (Terminal or Command) and type the commands:
-
- python --version
-
- If all is well, it will print "Python 3.X.X", where the version number
- is at least 3.10.*, and not higher than 3.11.*.
-
- If this works, check the version of the Python package manager, pip:
-
- pip --version
-
- You should get a message that indicates that the pip package
- installer was derived from Python 3.10 or 3.11. For example:
- "pip 22.0.1 from /usr/bin/pip (python 3.10)"
-
-Long Paths on Windows:
-
- If you are on Windows, you will need to enable Windows Long Paths to
- run InvokeAI successfully. If you're not sure what this is, you
- almost certainly need to do this.
-
- Simply double-click the "WinLongPathsEnabled.reg" file located in
- this directory, and approve the Windows warnings. Note that you will
- need to have admin privileges in order to do this.
-
-Launching the installer:
-
- Windows: double-click the 'install.bat' file (while keeping it inside
- the InvokeAI-Installer folder).
-
- Linux and Mac: Please open the terminal application and run
- './install.sh' (while keeping it inside the InvokeAI-Installer
- folder).
-
-The installer will create a directory of your choice and install the
-InvokeAI application within it. This directory contains everything you need to run
-invokeai. Once InvokeAI is up and running, you may delete the
-InvokeAI-Installer folder at your convenience.
-
-For more information, please see
-https://invoke-ai.github.io/InvokeAI/installation/INSTALL_AUTOMATED/
diff --git a/installer/templates/invoke.bat.in b/installer/templates/invoke.bat.in
deleted file mode 100644
index c8ef19710bc..00000000000
--- a/installer/templates/invoke.bat.in
+++ /dev/null
@@ -1,54 +0,0 @@
-@echo off
-
-PUSHD "%~dp0"
-setlocal
-
-call .venv\Scripts\activate.bat
-set INVOKEAI_ROOT=.
-
-:start
-echo Desired action:
-echo 1. Generate images with the browser-based interface
-echo 2. Open the developer console
-echo 3. Command-line help
-echo Q - Quit
-echo.
-echo To update, download and run the installer from https://github.com/invoke-ai/InvokeAI/releases/latest.
-echo.
-set /P choice="Please enter 1-4, Q: [1] "
-if not defined choice set choice=1
-IF /I "%choice%" == "1" (
- echo Starting the InvokeAI browser-based UI..
- python .venv\Scripts\invokeai-web.exe %*
-) ELSE IF /I "%choice%" == "2" (
- echo Developer Console
- echo Python command is:
- where python
- echo Python version is:
- python --version
- echo *************************
- echo You are now in the system shell, with the local InvokeAI Python virtual environment activated,
- echo so that you can troubleshoot this InvokeAI installation as necessary.
- echo *************************
- echo *** Type `exit` to quit this shell and deactivate the Python virtual environment ***
- call cmd /k
-) ELSE IF /I "%choice%" == "3" (
- echo Displaying command line help...
- python .venv\Scripts\invokeai-web.exe --help %*
- pause
- exit /b
-) ELSE IF /I "%choice%" == "q" (
- echo Goodbye!
- goto ending
-) ELSE (
- echo Invalid selection
- pause
- exit /b
-)
-goto start
-
-endlocal
-pause
-
-:ending
-exit /b
diff --git a/installer/templates/invoke.sh.in b/installer/templates/invoke.sh.in
deleted file mode 100644
index b8d5a7af23e..00000000000
--- a/installer/templates/invoke.sh.in
+++ /dev/null
@@ -1,87 +0,0 @@
-#!/bin/bash
-
-# MIT License
-
-# Coauthored by Lincoln Stein, Eugene Brodsky and Joshua Kimsey
-# Copyright 2023, The InvokeAI Development Team
-
-####
-# This launch script assumes that:
-# 1. it is located in the runtime directory,
-# 2. the .venv is also located in the runtime directory and is named exactly that
-#
-# If both of the above are not true, this script will likely not work as intended.
-# Activate the virtual environment and run `invoke.py` directly.
-####
-
-set -eu
-
-# Ensure we're in the correct folder in case user's CWD is somewhere else
-scriptdir=$(dirname "$0")
-cd "$scriptdir"
-
-. .venv/bin/activate
-
-export INVOKEAI_ROOT="$scriptdir"
-
-# Stash the CLI args - when we prompt for user input, `$@` is overwritten
-PARAMS=$@
-
-# This setting allows torch to fall back to CPU for operations that are not supported by MPS on macOS.
-if [ "$(uname -s)" == "Darwin" ]; then
- export PYTORCH_ENABLE_MPS_FALLBACK=1
-fi
-
-# Primary function for the case statement to determine user input
-do_choice() {
- case $1 in
- 1)
- clear
- printf "Generate images with a browser-based interface\n"
- invokeai-web $PARAMS
- ;;
- 2)
- clear
- printf "Open the developer console\n"
- file_name=$(basename "${BASH_SOURCE[0]}")
- bash --init-file "$file_name"
- ;;
- 3)
- clear
- printf "Command-line help\n"
- invokeai-web --help
- ;;
- *)
- clear
- printf "Exiting...\n"
- exit
- ;;
- esac
- clear
-}
-
-# Command-line interface for launching Invoke functions
-do_line_input() {
- clear
- printf "What would you like to do?\n"
- printf "1: Generate images using the browser-based interface\n"
- printf "2: Open the developer console\n"
- printf "3: Command-line help\n"
- printf "Q: Quit\n\n"
- printf "To update, download and run the installer from https://github.com/invoke-ai/InvokeAI/releases/latest.\n\n"
- read -p "Please enter 1-4, Q: [1] " yn
- choice=${yn:='1'}
- do_choice $choice
- clear
-}
-
-# Main IF statement for launching Invoke, and for checking if the user is in the developer console
-if [ "$0" != "bash" ]; then
- while true; do
- do_line_input
- done
-else # in developer console
- python --version
- printf "Press ^D to exit\n"
- export PS1="(InvokeAI) \u@\h \w> "
-fi
diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py
new file mode 100644
index 00000000000..1df1ed6e250
--- /dev/null
+++ b/invokeai/app/api/auth_dependencies.py
@@ -0,0 +1,166 @@
+"""FastAPI dependencies for authentication."""
+
+from typing import Annotated
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.auth.token_service import TokenData, verify_token
+from invokeai.backend.util.logging import logging
+
+logger = logging.getLogger(__name__)
+
+# HTTP Bearer token security scheme
+security = HTTPBearer(auto_error=False)
+
+
+async def get_current_user(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
+) -> TokenData:
+ """Get current authenticated user from Bearer token.
+
+ Note: This function accesses ApiDependencies.invoker.services.users directly,
+ which is the established pattern in this codebase. The ApiDependencies.invoker
+ is initialized in the FastAPI lifespan context before any requests are handled.
+
+ Args:
+ credentials: The HTTP authorization credentials containing the Bearer token
+
+ Returns:
+ TokenData containing user information from the token
+
+ Raises:
+ HTTPException: If token is missing, invalid, or expired (401 Unauthorized)
+ """
+ if credentials is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Missing authentication credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ token = credentials.credentials
+ token_data = verify_token(token)
+
+ if token_data is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired authentication token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # Verify user still exists and is active
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.get(token_data.user_id)
+
+ if user is None or not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="User account is inactive or does not exist",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ return token_data
+
+
+async def get_current_user_or_default(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)],
+) -> TokenData:
+ """Get current authenticated user from Bearer token, or return a default system user if not authenticated.
+
+ This dependency is useful for endpoints that should work in both single-user and multiuser modes.
+
+ When multiuser mode is disabled (default), this always returns a system user with admin privileges,
+ allowing unrestricted access to all operations.
+
+ When multiuser mode is enabled, authentication is required and this function validates the token,
+ returning authenticated user data or raising 401 Unauthorized if no valid credentials are provided.
+
+ Args:
+ credentials: The HTTP authorization credentials containing the Bearer token
+
+ Returns:
+ TokenData containing user information from the token, or system user in single-user mode
+
+ Raises:
+ HTTPException: 401 Unauthorized if in multiuser mode and credentials are missing, invalid, or user is inactive
+ """
+ # Get configuration to check if multiuser is enabled
+ config = ApiDependencies.invoker.services.configuration
+
+ # In single-user mode (multiuser=False), always return system user with admin privileges
+ if not config.multiuser:
+ return TokenData(user_id="system", email="system@system.invokeai", is_admin=True)
+
+ # Multiuser mode is enabled - validate credentials
+ if credentials is None:
+ # In multiuser mode, authentication is required
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
+
+ token = credentials.credentials
+ token_data = verify_token(token)
+
+ if token_data is None:
+ # Invalid token in multiuser mode - reject
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
+
+ # Verify user still exists and is active
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.get(token_data.user_id)
+
+ if user is None or not user.is_active:
+ # User doesn't exist or is inactive in multiuser mode - reject
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
+
+ return token_data
+
+
+async def require_admin(
+ current_user: Annotated[TokenData, Depends(get_current_user)],
+) -> TokenData:
+ """Require admin role for the current user.
+
+ Args:
+ current_user: The current authenticated user's token data
+
+ Returns:
+ The token data if user is an admin
+
+ Raises:
+ HTTPException: If user does not have admin privileges (403 Forbidden)
+ """
+ if not current_user.is_admin:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
+ return current_user
+
+
+async def require_admin_or_default(
+ current_user: Annotated[TokenData, Depends(get_current_user_or_default)],
+) -> TokenData:
+ """Require admin role for the current user, or return default system admin in single-user mode.
+
+ This dependency is useful for admin-only endpoints that should work in both single-user and multiuser modes.
+
+ When multiuser mode is disabled (default), this always returns a system user with admin privileges.
+ When multiuser mode is enabled, this validates that the authenticated user has admin privileges.
+
+ Args:
+ current_user: The current authenticated user's token data (or default system user)
+
+ Returns:
+ The token data if user is an admin (or system user in single-user mode)
+
+ Raises:
+ HTTPException: If user does not have admin privileges (403 Forbidden) in multiuser mode
+ """
+ if not current_user.is_admin:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
+ return current_user
+
+
+# Type aliases for convenient use in route dependencies
+CurrentUser = Annotated[TokenData, Depends(get_current_user)]
+CurrentUserOrDefault = Annotated[TokenData, Depends(get_current_user_or_default)]
+AdminUser = Annotated[TokenData, Depends(require_admin)]
+AdminUserOrDefault = Annotated[TokenData, Depends(require_admin_or_default)]
diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py
index 19a7bb083dc..e7468c1bca4 100644
--- a/invokeai/app/api/dependencies.py
+++ b/invokeai/app/api/dependencies.py
@@ -1,40 +1,72 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
+import asyncio
from logging import Logger
import torch
+from invokeai.app.services.app_settings import AppSettingsService
+from invokeai.app.services.auth.token_service import set_jwt_secret
+from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
+from invokeai.app.services.board_images.board_images_default import BoardImagesService
+from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+from invokeai.app.services.boards.boards_default import BoardService
+from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
+from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
+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 (
+ AlibabaCloudProvider,
+ GeminiProvider,
+ OpenAIProvider,
+ SeedreamProvider,
+)
+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_sqlite import SqliteImageRecordStorage
+from invokeai.app.services.images.images_default import ImageService
+from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
+from invokeai.app.services.invocation_services import InvocationServices
+from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.model_images.model_images_default import ModelImageFileStorageDisk
+from invokeai.app.services.model_manager.model_manager_default import ModelManagerService
+from invokeai.app.services.model_records.model_records_sql import ModelRecordServiceSQL
+from invokeai.app.services.model_relationship_records.model_relationship_records_sqlite import (
+ SqliteModelRelationshipRecordStorage,
+)
+from invokeai.app.services.model_relationships.model_relationships_default import ModelRelationshipsService
+from invokeai.app.services.names.names_default import SimpleNameService
from invokeai.app.services.object_serializer.object_serializer_disk import ObjectSerializerDisk
from invokeai.app.services.object_serializer.object_serializer_forward_cache import ObjectSerializerForwardCache
+from invokeai.app.services.session_processor.session_processor_default import (
+ DefaultSessionProcessor,
+ DefaultSessionRunner,
+)
+from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.sqlite.sqlite_util import init_db
-from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
+from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk
+from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage
+from invokeai.app.services.urls.urls_default import LocalUrlService
+from invokeai.app.services.users.users_default import UserService
+from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
+from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ AnimaConditioningInfo,
+ BasicConditioningInfo,
+ CogView4ConditioningInfo,
+ ConditioningFieldData,
+ FLUXConditioningInfo,
+ QwenImageConditioningInfo,
+ SD3ConditioningInfo,
+ SDXLConditioningInfo,
+ ZImageConditioningInfo,
+)
from invokeai.backend.util.logging import InvokeAILogger
from invokeai.version.invokeai_version import __version__
-from ..services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
-from ..services.board_images.board_images_default import BoardImagesService
-from ..services.board_records.board_records_sqlite import SqliteBoardRecordStorage
-from ..services.boards.boards_default import BoardService
-from ..services.bulk_download.bulk_download_default import BulkDownloadService
-from ..services.config import InvokeAIAppConfig
-from ..services.download import DownloadQueueService
-from ..services.events.events_fastapievents import FastAPIEventService
-from ..services.image_files.image_files_disk import DiskImageFileStorage
-from ..services.image_records.image_records_sqlite import SqliteImageRecordStorage
-from ..services.images.images_default import ImageService
-from ..services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
-from ..services.invocation_services import InvocationServices
-from ..services.invocation_stats.invocation_stats_default import InvocationStatsService
-from ..services.invoker import Invoker
-from ..services.model_images.model_images_default import ModelImageFileStorageDisk
-from ..services.model_manager.model_manager_default import ModelManagerService
-from ..services.model_records import ModelRecordServiceSQL
-from ..services.names.names_default import SimpleNameService
-from ..services.session_processor.session_processor_default import DefaultSessionProcessor, DefaultSessionRunner
-from ..services.session_queue.session_queue_sqlite import SqliteSessionQueue
-from ..services.urls.urls_default import LocalUrlService
-from ..services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
-
# TODO: is there a better way to achieve this?
def check_internet() -> bool:
@@ -61,7 +93,12 @@ class ApiDependencies:
invoker: Invoker
@staticmethod
- def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger = logger) -> None:
+ def initialize(
+ config: InvokeAIAppConfig,
+ event_handler_id: int,
+ loop: asyncio.AbstractEventLoop,
+ logger: Logger = logger,
+ ) -> None:
logger.info(f"InvokeAI version {__version__}")
logger.info(f"Root directory = {str(config.root_path)}")
@@ -72,9 +109,17 @@ def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger
image_files = DiskImageFileStorage(f"{output_folder}/images")
model_images_folder = config.models_path
+ style_presets_folder = config.style_presets_path
+ workflow_thumbnails_folder = config.workflow_thumbnails_path
db = init_db(config=config, logger=logger, image_files=image_files)
+ # Initialize JWT secret from database
+ app_settings = AppSettingsService(db=db)
+ jwt_secret = app_settings.get_jwt_secret()
+ set_jwt_secret(jwt_secret)
+ logger.info("JWT secret loaded from database")
+
configuration = config
logger = logger
@@ -82,31 +127,67 @@ def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger
board_images = BoardImagesService()
board_records = SqliteBoardRecordStorage(db=db)
boards = BoardService()
- events = FastAPIEventService(event_handler_id)
+ events = FastAPIEventService(event_handler_id, loop=loop)
bulk_download = BulkDownloadService()
image_records = SqliteImageRecordStorage(db=db)
images = ImageService()
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
tensors = ObjectSerializerForwardCache(
- ObjectSerializerDisk[torch.Tensor](output_folder / "tensors", ephemeral=True)
+ ObjectSerializerDisk[torch.Tensor](
+ output_folder / "tensors",
+ safe_globals=[torch.Tensor],
+ ephemeral=True,
+ ),
)
conditioning = ObjectSerializerForwardCache(
- ObjectSerializerDisk[ConditioningFieldData](output_folder / "conditioning", ephemeral=True)
+ ObjectSerializerDisk[ConditioningFieldData](
+ output_folder / "conditioning",
+ safe_globals=[
+ ConditioningFieldData,
+ BasicConditioningInfo,
+ SDXLConditioningInfo,
+ FLUXConditioningInfo,
+ SD3ConditioningInfo,
+ CogView4ConditioningInfo,
+ ZImageConditioningInfo,
+ QwenImageConditioningInfo,
+ AnimaConditioningInfo,
+ ],
+ ephemeral=True,
+ ),
)
download_queue_service = DownloadQueueService(app_config=configuration, event_bus=events)
- model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images")
+ model_record_service = ModelRecordServiceSQL(db=db, logger=logger)
model_manager = ModelManagerService.build_model_manager(
app_config=configuration,
- model_record_service=ModelRecordServiceSQL(db=db),
+ model_record_service=model_record_service,
download_queue=download_queue_service,
events=events,
)
+ external_generation = ExternalGenerationService(
+ providers={
+ AlibabaCloudProvider.provider_id: AlibabaCloudProvider(app_config=configuration, logger=logger),
+ GeminiProvider.provider_id: GeminiProvider(app_config=configuration, logger=logger),
+ OpenAIProvider.provider_id: OpenAIProvider(app_config=configuration, logger=logger),
+ SeedreamProvider.provider_id: SeedreamProvider(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 = SqliteModelRelationshipRecordStorage(db=db)
names = SimpleNameService()
performance_statistics = InvocationStatsService()
session_processor = DefaultSessionProcessor(session_runner=DefaultSessionRunner())
session_queue = SqliteSessionQueue(db=db)
urls = LocalUrlService()
workflow_records = SqliteWorkflowRecordsStorage(db=db)
+ style_preset_records = SqliteStylePresetRecordsStorage(db=db)
+ style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
+ workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder)
+ client_state_persistence = ClientStatePersistenceSqlite(db=db)
+ users = UserService(db=db)
services = InvocationServices(
board_image_records=board_image_records,
@@ -123,7 +204,10 @@ def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger
logger=logger,
model_images=model_images_service,
model_manager=model_manager,
+ 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,
@@ -132,9 +216,24 @@ def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger
workflow_records=workflow_records,
tensors=tensors,
conditioning=conditioning,
+ style_preset_records=style_preset_records,
+ style_preset_image_files=style_preset_image_files,
+ workflow_thumbnails=workflow_thumbnails,
+ client_state_persistence=client_state_persistence,
+ users=users,
)
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
diff --git a/invokeai/app/api/extract_metadata_from_image.py b/invokeai/app/api/extract_metadata_from_image.py
new file mode 100644
index 00000000000..642a4b1bce1
--- /dev/null
+++ b/invokeai/app/api/extract_metadata_from_image.py
@@ -0,0 +1,134 @@
+import json
+import logging
+from dataclasses import dataclass
+
+from PIL import Image
+
+from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator
+
+
+@dataclass
+class ExtractedMetadata:
+ invokeai_metadata: str | None
+ invokeai_workflow: str | None
+ invokeai_graph: str | None
+
+
+def extract_metadata_from_image(
+ pil_image: Image.Image,
+ invokeai_metadata_override: str | None,
+ invokeai_workflow_override: str | None,
+ invokeai_graph_override: str | None,
+ logger: logging.Logger,
+) -> ExtractedMetadata:
+ """
+ Extracts the "invokeai_metadata", "invokeai_workflow", and "invokeai_graph" data embedded in the PIL Image.
+
+ These items are stored as stringified JSON in the image file's metadata, so we need to do some parsing to validate
+ them. Once parsed, the values are returned as they came (as strings), or None if they are not present or invalid.
+
+ In some situations, we may prefer to override the values extracted from the image file with some other values.
+
+ For example, when uploading an image via API, the client can optionally provide the metadata directly in the request,
+ as opposed to embedding it in the image file. In this case, the client-provided metadata will be used instead of the
+ metadata embedded in the image file.
+
+ Args:
+ pil_image: The PIL Image object.
+ invokeai_metadata_override: The metadata override provided by the client.
+ invokeai_workflow_override: The workflow override provided by the client.
+ invokeai_graph_override: The graph override provided by the client.
+ logger: The logger to use for debug logging.
+
+ Returns:
+ ExtractedMetadata: The extracted metadata, workflow, and graph.
+ """
+
+ # The fallback value for metadata is None.
+ stringified_metadata: str | None = None
+
+ # Use the metadata override if provided, else attempt to extract it from the image file.
+ metadata_raw = (
+ invokeai_metadata_override
+ if invokeai_metadata_override is not None
+ else pil_image.info.get("invokeai_metadata", None)
+ )
+
+ # If the metadata is present in the image file, we will attempt to parse it as JSON. When we create images,
+ # we always store metadata as a stringified JSON dict. So, we expect it to be a string here.
+ if isinstance(metadata_raw, str):
+ try:
+ # Must be a JSON string
+ metadata_parsed = json.loads(metadata_raw)
+ # Must be a dict
+ if isinstance(metadata_parsed, dict):
+ # Looks good, overwrite the fallback value
+ stringified_metadata = metadata_raw
+ except Exception as e:
+ logger.debug(f"Failed to parse metadata for uploaded image, {e}")
+ pass
+
+ # We expect the workflow, if embedded in the image, to be a JSON-stringified WorkflowWithoutID. We will store it
+ # as a string.
+ workflow_raw: str | None = (
+ invokeai_workflow_override
+ if invokeai_workflow_override is not None
+ else pil_image.info.get("invokeai_workflow", None)
+ )
+
+ # The fallback value for workflow is None.
+ stringified_workflow: str | None = None
+
+ # If the workflow is present in the image file, we will attempt to parse it as JSON. When we create images, we
+ # always store workflows as a stringified JSON WorkflowWithoutID. So, we expect it to be a string here.
+ if isinstance(workflow_raw, str):
+ try:
+ # Validate the workflow JSON before storing it
+ WorkflowWithoutIDValidator.validate_json(workflow_raw)
+ # Looks good, overwrite the fallback value
+ stringified_workflow = workflow_raw
+ except Exception:
+ logger.debug("Failed to parse workflow for uploaded image")
+ pass
+
+ # We expect the workflow, if embedded in the image, to be a JSON-stringified Graph. We will store it as a
+ # string.
+ graph_raw: str | None = (
+ invokeai_graph_override if invokeai_graph_override is not None else pil_image.info.get("invokeai_graph", None)
+ )
+
+ # The fallback value for graph is None.
+ stringified_graph: str | None = None
+
+ # If the graph is present in the image file, we will attempt to parse it as JSON. When we create images, we
+ # always store graphs as a stringified JSON Graph. So, we expect it to be a string here.
+ if isinstance(graph_raw, str):
+ try:
+ # TODO(psyche): Due to pydantic's handling of None values, it is possible for the graph to fail validation,
+ # even if it is a direct dump of a valid graph. Node fields in the graph are allowed to have be unset if
+ # they have incoming connections, but something about the ser/de process cannot adequately handle this.
+ #
+ # In lieu of fixing the graph validation, we will just do a simple check here to see if the graph is dict
+ # with the correct keys. This is not a perfect solution, but it should be good enough for now.
+
+ # FIX ME: Validate the graph JSON before storing it
+ # Graph.model_validate_json(graph_raw)
+
+ # Crappy workaround to validate JSON
+ graph_parsed = json.loads(graph_raw)
+ if not isinstance(graph_parsed, dict):
+ raise ValueError("Not a dict")
+ if not isinstance(graph_parsed.get("nodes", None), dict):
+ raise ValueError("'nodes' is not a dict")
+ if not isinstance(graph_parsed.get("edges", None), list):
+ raise ValueError("'edges' is not a list")
+
+ # Looks good, overwrite the fallback value
+ stringified_graph = graph_raw
+ except Exception as e:
+ logger.debug(f"Failed to parse graph for uploaded image, {e}")
+ pass
+
+ return ExtractedMetadata(
+ invokeai_metadata=stringified_metadata, invokeai_workflow=stringified_workflow, invokeai_graph=stringified_graph
+ )
diff --git a/invokeai/app/api/no_cache_staticfiles.py b/invokeai/app/api/no_cache_staticfiles.py
index 15a53270f1d..cbf82d99c71 100644
--- a/invokeai/app/api/no_cache_staticfiles.py
+++ b/invokeai/app/api/no_cache_staticfiles.py
@@ -1,7 +1,9 @@
from typing import Any
+from starlette.exceptions import HTTPException
from starlette.responses import Response
from starlette.staticfiles import StaticFiles
+from starlette.types import Scope
class NoCacheStaticFiles(StaticFiles):
@@ -12,6 +14,10 @@ class NoCacheStaticFiles(StaticFiles):
Static files include the javascript bundles, fonts, locales, and some images. Generated
images are not included, as they are served by a router.
+
+ This class also implements proper SPA (Single Page Application) routing by serving index.html
+ for any routes that don't match static files, enabling client-side routing to work correctly
+ in production builds.
"""
def __init__(self, *args: Any, **kwargs: Any):
@@ -26,3 +32,19 @@ def file_response(self, *args: Any, **kwargs: Any) -> Response:
resp.headers.setdefault("Pragma", self.pragma)
resp.headers.setdefault("Expires", self.expires)
return resp
+
+ async def get_response(self, path: str, scope: Scope) -> Response:
+ """
+ Override get_response to implement SPA routing.
+
+ When a file is not found and html mode is enabled, serve index.html instead of raising a 404.
+ This allows client-side routing to work correctly in SPAs.
+ """
+ try:
+ return await super().get_response(path, scope)
+ except HTTPException as exc:
+ # If the file is not found (404) and html mode is enabled, serve index.html
+ # This allows client-side routing to handle the path
+ if exc.status_code == 404 and self.html:
+ return await super().get_response("index.html", scope)
+ raise
diff --git a/invokeai/app/api/routers/_access.py b/invokeai/app/api/routers/_access.py
new file mode 100644
index 00000000000..fae3971a144
--- /dev/null
+++ b/invokeai/app/api/routers/_access.py
@@ -0,0 +1,92 @@
+"""Cross-router authorization helpers.
+
+These helpers are imported by multiple router modules. Keep them free of router
+specifics so any route can call them after resolving `current_user`.
+"""
+
+from fastapi import HTTPException
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+
+def assert_image_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user does not own the image and is not an admin.
+
+ Ownership is satisfied when ANY of these hold:
+ - The user is an admin.
+ - The user is the image's direct owner (image_records.user_id).
+ - The user owns the board the image sits on.
+ - The image sits on a Public board (public boards grant mutation rights).
+ """
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to modify this image")
+
+
+def assert_image_read_access(image_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not view the image.
+
+ Access is granted when ANY of these hold:
+ - The user is an admin.
+ - The user owns the image.
+ - The image sits on a shared or public board.
+ """
+ if current_user.is_admin:
+ return
+
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+
+ board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ return
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail="Not authorized to access this image")
+
+
+def assert_board_read_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not read images from this board.
+
+ Access is granted when ANY of these hold:
+ - The user is an admin.
+ - The user owns the board.
+ - The board visibility is Shared or Public.
+ """
+ if current_user.is_admin:
+ return
+
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ if board.user_id == current_user.user_id:
+ return
+
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ return
+
+ raise HTTPException(status_code=403, detail="Not authorized to access this board")
diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py
index c3bc98a0387..a8e0c68d781 100644
--- a/invokeai/app/api/routers/app_info.py
+++ b/invokeai/app/api/routers/app_info.py
@@ -1,23 +1,36 @@
-import typing
+import locale
+import re
from enum import Enum
-from importlib.metadata import PackageNotFoundError, version
-from pathlib import Path
-from platform import python_version
-from typing import Optional
+from importlib.metadata import distributions
+from pathlib import Path as FilePath
+from threading import Lock
+from typing import Any, Literal, Union
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.invocations.upscale import ESRGAN_MODELS
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+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,
+ IMAGE_SUBFOLDER_STRATEGY,
+ 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__
-from ..dependencies import ApiDependencies
-
class LogLevel(int, Enum):
NotSet = logging.NOTSET
@@ -28,11 +41,6 @@ class LogLevel(int, Enum):
Critical = logging.CRITICAL
-class Upscaler(BaseModel):
- upscaling_method: str = Field(description="Name of upscaling method")
- upscaling_models: list[str] = Field(description="List of upscaling models for this method")
-
-
app_router = APIRouter(prefix="/v1/app", tags=["app"])
@@ -42,84 +50,346 @@ class AppVersion(BaseModel):
version: str = Field(description="App version")
-class AppDependencyVersions(BaseModel):
- """App depencency Versions Response"""
+@app_router.get("/version", operation_id="app_version", status_code=200, response_model=AppVersion)
+async def get_version() -> AppVersion:
+ return AppVersion(version=__version__)
- accelerate: str = Field(description="accelerate version")
- compel: str = Field(description="compel version")
- cuda: Optional[str] = Field(description="CUDA version")
- diffusers: str = Field(description="diffusers version")
- numpy: str = Field(description="Numpy version")
- opencv: str = Field(description="OpenCV version")
- onnx: str = Field(description="ONNX version")
- pillow: str = Field(description="Pillow (PIL) version")
- python: str = Field(description="Python version")
- torch: str = Field(description="PyTorch version")
- torchvision: str = Field(description="PyTorch Vision version")
- transformers: str = Field(description="transformers version")
- xformers: Optional[str] = Field(description="xformers version")
+@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=dict[str, str])
+async def get_app_deps() -> dict[str, str]:
+ deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()}
+ try:
+ cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue]
+ except Exception:
+ cuda = "N/A"
-class AppConfig(BaseModel):
- """App Config Response"""
+ deps["CUDA"] = cuda
- infill_methods: list[str] = Field(description="List of available infill methods")
- upscaling_methods: list[Upscaler] = Field(description="List of upscaling methods")
- nsfw_methods: list[str] = Field(description="List of NSFW checking methods")
- watermarking_methods: list[str] = Field(description="List of invisible watermark methods")
+ sorted_deps = dict(sorted(deps.items(), key=lambda item: item[0].lower()))
+ return sorted_deps
-@app_router.get("/version", operation_id="app_version", status_code=200, response_model=AppVersion)
-async def get_version() -> AppVersion:
- return AppVersion(version=__version__)
+@app_router.get("/patchmatch_status", operation_id="get_patchmatch_status", status_code=200, response_model=bool)
+async def get_patchmatch_status() -> bool:
+ return PatchMatch.patchmatch_available()
-@app_router.get("/app_deps", operation_id="get_app_deps", status_code=200, response_model=AppDependencyVersions)
-async def get_app_deps() -> AppDependencyVersions:
- try:
- xformers = version("xformers")
- except PackageNotFoundError:
- xformers = None
- return AppDependencyVersions(
- accelerate=version("accelerate"),
- compel=version("compel"),
- cuda=torch.version.cuda,
- diffusers=version("diffusers"),
- numpy=version("numpy"),
- opencv=version("opencv-python"),
- onnx=version("onnx"),
- pillow=version("pillow"),
- python=python_version(),
- torch=torch.version.__version__,
- torchvision=version("torchvision"),
- transformers=version("transformers"),
- xformers=xformers,
+
+class InvokeAIAppConfigWithSetFields(BaseModel):
+ """InvokeAI App Config with model fields set"""
+
+ set_fields: set[str] = Field(description="The set fields")
+ 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]] = {
+ "alibabacloud": ("external_alibabacloud_api_key", "external_alibabacloud_base_url"),
+ "gemini": ("external_gemini_api_key", "external_gemini_base_url"),
+ "openai": ("external_openai_api_key", "external_openai_base_url"),
+ "seedream": ("external_seedream_api_key", "external_seedream_base_url"),
+}
+_EXTERNAL_PROVIDER_CONFIG_LOCK = Lock()
+
+
+def _remove_nullable_default_from_schema(schema: dict[str, Any]) -> None:
+ schema.pop("default", None)
+ any_of = schema.pop("anyOf", None)
+ if isinstance(any_of, list):
+ non_null_schemas = [
+ subschema for subschema in any_of if isinstance(subschema, dict) and subschema.get("type") != "null"
+ ]
+ if len(non_null_schemas) == 1:
+ schema.update(non_null_schemas[0])
+
+
+_GENERATION_DEVICE_PATTERN = re.compile(r"^(cpu|mps|cuda(:\d+)?)$")
+
+
+class GenerationDeviceOption(BaseModel):
+ """A device that may be selected for generation."""
+
+ device: str = Field(description="The device identifier, e.g. 'cuda:0', 'mps', or 'cpu'")
+ name: str = Field(description="Human-readable device name")
+
+
+class UpdateAppGenerationSettingsRequest(BaseModel):
+ """Writable generation-related app settings."""
+
+ image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY | None = Field(
+ default=None,
+ description="Strategy for organizing images into subfolders.",
+ json_schema_extra=_remove_nullable_default_from_schema,
+ )
+ max_queue_history: int | None = Field(
+ default=None,
+ ge=0,
+ description="Keep the last N completed, failed, and canceled queue items on startup. Set to 0 to prune all terminal items.",
+ )
+ generation_devices: Union[Literal["auto"], list[str]] | None = Field(
+ default=None,
+ description="Devices to use for parallel generation. `auto` uses every available GPU; provide an explicit list (e.g. `[cuda:0, cuda:1]`) to use specific devices. Takes effect after restarting InvokeAI.",
+ json_schema_extra=_remove_nullable_default_from_schema,
+ )
+
+ @field_validator("generation_devices")
+ @classmethod
+ def validate_generation_devices(
+ cls, v: Union[Literal["auto"], list[str], None]
+ ) -> Union[Literal["auto"], list[str], None]:
+ if v is None or v == "auto":
+ return v
+ for device in v:
+ if not _GENERATION_DEVICE_PATTERN.match(device):
+ raise ValueError(
+ f"Invalid generation device '{device}'. Valid values are 'auto', 'cpu', 'mps', 'cuda', or 'cuda:N'."
+ )
+ return v
+
+ @model_validator(mode="after")
+ def validate_explicit_nulls(self) -> "UpdateAppGenerationSettingsRequest":
+ if "image_subfolder_strategy" in self.model_fields_set and self.image_subfolder_strategy is None:
+ raise ValueError("image_subfolder_strategy may not be null")
+ if "generation_devices" in self.model_fields_set and self.generation_devices is None:
+ raise ValueError("generation_devices may not be null")
+ return self
+
+
+@app_router.get(
+ "/generation_device_options",
+ operation_id="get_generation_device_options",
+ status_code=200,
+ response_model=list[GenerationDeviceOption],
+)
+async def get_generation_device_options() -> list[GenerationDeviceOption]:
+ """List the devices available for generation, for use with the `generation_devices` setting."""
+ options: list[GenerationDeviceOption] = []
+ if torch.cuda.is_available():
+ for index in range(torch.cuda.device_count()):
+ device = f"cuda:{index}"
+ try:
+ name = torch.cuda.get_device_name(index)
+ except Exception:
+ name = device
+ options.append(GenerationDeviceOption(device=device, name=name))
+ elif torch.backends.mps.is_available():
+ options.append(GenerationDeviceOption(device="mps", name="Apple MPS"))
+ else:
+ options.append(GenerationDeviceOption(device="cpu", name="CPU"))
+ return options
+
+
+@app_router.get(
+ "/runtime_config", operation_id="get_runtime_config", status_code=200, response_model=InvokeAIAppConfigWithSetFields
+)
+async def get_runtime_config() -> InvokeAIAppConfigWithSetFields:
+ config = get_config()
+ return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config)
+
+
+@app_router.patch(
+ "/runtime_config",
+ operation_id="update_runtime_config",
+ status_code=200,
+ response_model=InvokeAIAppConfigWithSetFields,
+)
+async def update_runtime_config(
+ _: AdminUserOrDefault,
+ changes: UpdateAppGenerationSettingsRequest = Body(description="Writable runtime configuration changes"),
+) -> InvokeAIAppConfigWithSetFields:
+ with _EXTERNAL_PROVIDER_CONFIG_LOCK:
+ config = get_config()
+ update_dict = changes.model_dump(exclude_unset=True)
+ config.update_config(update_dict)
+
+ if config.config_file_path.exists():
+ persisted_config = load_and_migrate_config(config.config_file_path)
+ else:
+ persisted_config = DefaultInvokeAIAppConfig()
+
+ persisted_config.update_config(update_dict)
+ persisted_config.write_file(config.config_file_path)
+ 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(
+ _: AdminUserOrDefault,
+ 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(
+ _: AdminUserOrDefault,
+ 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,
)
-@app_router.get("/config", operation_id="get_config", status_code=200, response_model=AppConfig)
-async def get_config() -> AppConfig:
- infill_methods = ["tile", "lama", "cv2", "color"] # TODO: add mosaic back
- if PatchMatch.patchmatch_available():
- infill_methods.append("patchmatch")
+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)
+
- upscaling_models = []
- for model in typing.get_args(ESRGAN_MODELS):
- upscaling_models.append(str(Path(model).stem))
- upscaler = Upscaler(upscaling_method="esrgan", upscaling_models=upscaling_models)
+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()
- nsfw_methods = ["nsfw_checker"]
+ 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}
- watermarking_methods = ["invisible_watermark"]
+ if non_provider_updates:
+ file_config.update_config(non_provider_updates)
- return AppConfig(
- infill_methods=infill_methods,
- upscaling_methods=[upscaler],
- nsfw_methods=nsfw_methods,
- watermarking_methods=watermarking_methods,
+ 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",
diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
new file mode 100644
index 00000000000..e0b0c885cd2
--- /dev/null
+++ b/invokeai/app/api/routers/auth.py
@@ -0,0 +1,536 @@
+"""Authentication endpoints."""
+
+import secrets
+import string
+from datetime import timedelta
+from typing import Annotated
+
+from fastapi import APIRouter, Body, HTTPException, Path, status
+from pydantic import BaseModel, Field, field_validator
+
+from invokeai.app.api.auth_dependencies import AdminUser, CurrentUser
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.auth.token_service import TokenData, create_access_token
+from invokeai.app.services.users.users_common import (
+ UserCreateRequest,
+ UserDTO,
+ UserUpdateRequest,
+ validate_email_with_special_domains,
+)
+
+auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"])
+
+# Token expiration constants (in days)
+TOKEN_EXPIRATION_NORMAL = 1 # 1 day for normal login
+TOKEN_EXPIRATION_REMEMBER_ME = 7 # 7 days for "remember me" login
+
+
+class LoginRequest(BaseModel):
+ """Request body for user login."""
+
+ email: str = Field(description="User email address")
+ password: str = Field(description="User password")
+ remember_me: bool = Field(default=False, description="Whether to extend session duration")
+
+ @field_validator("email")
+ @classmethod
+ def validate_email(cls, v: str) -> str:
+ """Validate email address, allowing special-use domains."""
+ return validate_email_with_special_domains(v)
+
+
+class LoginResponse(BaseModel):
+ """Response from successful login."""
+
+ token: str = Field(description="JWT access token")
+ user: UserDTO = Field(description="User information")
+ expires_in: int = Field(description="Token expiration time in seconds")
+
+
+class SetupRequest(BaseModel):
+ """Request body for initial admin setup."""
+
+ email: str = Field(description="Admin email address")
+ display_name: str | None = Field(default=None, description="Admin display name")
+ password: str = Field(description="Admin password")
+
+ @field_validator("email")
+ @classmethod
+ def validate_email(cls, v: str) -> str:
+ """Validate email address, allowing special-use domains."""
+ return validate_email_with_special_domains(v)
+
+
+class SetupResponse(BaseModel):
+ """Response from successful admin setup."""
+
+ success: bool = Field(description="Whether setup was successful")
+ user: UserDTO = Field(description="Created admin user information")
+
+
+class LogoutResponse(BaseModel):
+ """Response from logout."""
+
+ success: bool = Field(description="Whether logout was successful")
+
+
+class SetupStatusResponse(BaseModel):
+ """Response for setup status check."""
+
+ setup_required: bool = Field(description="Whether initial setup is required")
+ multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
+ strict_password_checking: bool = Field(description="Whether strict password requirements are enforced")
+ admin_email: str | None = Field(default=None, description="Email of the first active admin user, if any")
+
+
+@auth_router.get("/status", response_model=SetupStatusResponse)
+async def get_setup_status() -> SetupStatusResponse:
+ """Check if initial administrator setup is required.
+
+ Returns:
+ SetupStatusResponse indicating whether setup is needed and multiuser mode status
+ """
+ config = ApiDependencies.invoker.services.configuration
+
+ # If multiuser is disabled, setup is never required
+ if not config.multiuser:
+ return SetupStatusResponse(
+ setup_required=False,
+ multiuser_enabled=False,
+ strict_password_checking=config.strict_password_checking,
+ admin_email=None,
+ )
+
+ # In multiuser mode, check if an admin exists
+ user_service = ApiDependencies.invoker.services.users
+ setup_required = not user_service.has_admin()
+
+ # Only expose admin_email during initial setup to avoid leaking
+ # administrator identity on public deployments.
+ admin_email = user_service.get_admin_email() if setup_required else None
+
+ return SetupStatusResponse(
+ setup_required=setup_required,
+ multiuser_enabled=True,
+ strict_password_checking=config.strict_password_checking,
+ admin_email=admin_email,
+ )
+
+
+@auth_router.post("/login", response_model=LoginResponse)
+async def login(
+ request: Annotated[LoginRequest, Body(description="Login credentials")],
+) -> LoginResponse:
+ """Authenticate user and return access token.
+
+ Args:
+ request: Login credentials (email and password)
+
+ Returns:
+ LoginResponse containing JWT token and user information
+
+ Raises:
+ HTTPException: 401 if credentials are invalid or user is inactive
+ HTTPException: 403 if multiuser mode is disabled
+ """
+ config = ApiDependencies.invoker.services.configuration
+
+ # Check if multiuser is enabled
+ if not config.multiuser:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Multiuser mode is disabled. Authentication is not required in single-user mode.",
+ )
+
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.authenticate(request.email, request.password)
+
+ if user is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Incorrect email or password",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if not user.is_active:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled")
+
+ # Create token with appropriate expiration
+ expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME if request.remember_me else TOKEN_EXPIRATION_NORMAL)
+ token_data = TokenData(
+ user_id=user.user_id,
+ email=user.email,
+ is_admin=user.is_admin,
+ remember_me=request.remember_me,
+ )
+ token = create_access_token(token_data, expires_delta)
+
+ return LoginResponse(
+ token=token,
+ user=user,
+ expires_in=int(expires_delta.total_seconds()),
+ )
+
+
+@auth_router.post("/logout", response_model=LogoutResponse)
+async def logout(
+ current_user: CurrentUser,
+) -> LogoutResponse:
+ """Logout current user.
+
+ Currently a no-op since we use stateless JWT tokens. For token invalidation in
+ future implementations, consider:
+ - Token blacklist: Store invalidated tokens in Redis/database with expiration
+ - Token versioning: Add version field to user record, increment on logout
+ - Short-lived tokens: Use refresh token pattern with token rotation
+ - Session storage: Track active sessions server-side for revocation
+
+ Args:
+ current_user: The authenticated user (validates token)
+
+ Returns:
+ LogoutResponse indicating success
+ """
+ # TODO: Implement token invalidation when server-side session management is added
+ # For now, this is a no-op since we use stateless JWT tokens
+ return LogoutResponse(success=True)
+
+
+@auth_router.get("/me", response_model=UserDTO)
+async def get_current_user_info(
+ current_user: CurrentUser,
+) -> UserDTO:
+ """Get current authenticated user's information.
+
+ Args:
+ current_user: The authenticated user's token data
+
+ Returns:
+ UserDTO containing user information
+
+ Raises:
+ HTTPException: 404 if user is not found (should not happen normally)
+ """
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.get(current_user.user_id)
+
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+ return user
+
+
+@auth_router.post("/setup", response_model=SetupResponse)
+async def setup_admin(
+ request: Annotated[SetupRequest, Body(description="Admin account details")],
+) -> SetupResponse:
+ """Set up initial administrator account.
+
+ This endpoint can only be called once, when no admin user exists. It creates
+ the first admin user for the system.
+
+ Args:
+ request: Admin account details (email, display_name, password)
+
+ Returns:
+ SetupResponse containing the created admin user
+
+ Raises:
+ HTTPException: 400 if admin already exists or password is weak
+ HTTPException: 403 if multiuser mode is disabled
+ """
+ config = ApiDependencies.invoker.services.configuration
+
+ # Check if multiuser is enabled
+ if not config.multiuser:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Multiuser mode is disabled. Admin setup is not required in single-user mode.",
+ )
+
+ user_service = ApiDependencies.invoker.services.users
+
+ # Check if any admin exists
+ if user_service.has_admin():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Administrator account already configured",
+ )
+
+ # Create admin user - this will validate password strength
+ try:
+ user_data = UserCreateRequest(
+ email=request.email,
+ display_name=request.display_name,
+ password=request.password,
+ is_admin=True,
+ )
+ user = user_service.create_admin(user_data, strict_password_checking=config.strict_password_checking)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+
+ return SetupResponse(success=True, user=user)
+
+
+# ---------------------------------------------------------------------------
+# User management models
+# ---------------------------------------------------------------------------
+
+_PASSWORD_ALPHABET = string.ascii_letters + string.digits + string.punctuation
+
+
+class AdminUserCreateRequest(BaseModel):
+ """Request body for admin to create a new user."""
+
+ email: str = Field(description="User email address")
+ display_name: str | None = Field(default=None, description="Display name")
+ password: str = Field(description="User password")
+ is_admin: bool = Field(default=False, description="Whether user should have admin privileges")
+
+ @field_validator("email")
+ @classmethod
+ def validate_email(cls, v: str) -> str:
+ """Validate email address, allowing special-use domains."""
+ return validate_email_with_special_domains(v)
+
+
+class AdminUserUpdateRequest(BaseModel):
+ """Request body for admin to update any user."""
+
+ display_name: str | None = Field(default=None, description="Display name")
+ password: str | None = Field(default=None, description="New password")
+ is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges")
+ is_active: bool | None = Field(default=None, description="Whether user account should be active")
+
+
+class UserProfileUpdateRequest(BaseModel):
+ """Request body for a user to update their own profile."""
+
+ display_name: str | None = Field(default=None, description="New display name")
+ current_password: str | None = Field(default=None, description="Current password (required when changing password)")
+ new_password: str | None = Field(default=None, description="New password")
+
+
+class GeneratePasswordResponse(BaseModel):
+ """Response containing a generated password."""
+
+ password: str = Field(description="Generated strong password")
+
+
+# ---------------------------------------------------------------------------
+# User management endpoints
+# ---------------------------------------------------------------------------
+
+
+@auth_router.get("/generate-password", response_model=GeneratePasswordResponse)
+async def generate_password(
+ current_user: CurrentUser,
+) -> GeneratePasswordResponse:
+ """Generate a strong random password.
+
+ Returns a cryptographically secure random password of 16 characters
+ containing uppercase, lowercase, digits, and punctuation.
+ """
+ # Ensure the generated password always meets strength requirements:
+ # at least one uppercase, one lowercase, one digit, one special char.
+ while True:
+ password = "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(16))
+ if (
+ any(c.isupper() for c in password)
+ and any(c.islower() for c in password)
+ and any(c.isdigit() for c in password)
+ ):
+ return GeneratePasswordResponse(password=password)
+
+
+@auth_router.get("/users", response_model=list[UserDTO])
+async def list_users(
+ current_user: AdminUser,
+) -> list[UserDTO]:
+ """List all users. Requires admin privileges.
+
+ The internal 'system' user (created for backward compatibility) is excluded
+ from the results since it cannot be managed through this interface.
+
+ Returns:
+ List of all real users (system user excluded)
+ """
+ user_service = ApiDependencies.invoker.services.users
+ return [u for u in user_service.list_users() if u.user_id != "system"]
+
+
+@auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED)
+async def create_user(
+ request: Annotated[AdminUserCreateRequest, Body(description="New user details")],
+ current_user: AdminUser,
+) -> UserDTO:
+ """Create a new user. Requires admin privileges.
+
+ Args:
+ request: New user details
+
+ Returns:
+ The created user
+
+ Raises:
+ HTTPException: 400 if email already exists or password is weak
+ """
+ user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
+ try:
+ user_data = UserCreateRequest(
+ email=request.email,
+ display_name=request.display_name,
+ password=request.password,
+ is_admin=request.is_admin,
+ )
+ return user_service.create(user_data, strict_password_checking=config.strict_password_checking)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+
+
+@auth_router.get("/users/{user_id}", response_model=UserDTO)
+async def get_user(
+ user_id: Annotated[str, Path(description="User ID")],
+ current_user: AdminUser,
+) -> UserDTO:
+ """Get a user by ID. Requires admin privileges.
+
+ Args:
+ user_id: The user ID
+
+ Returns:
+ The user
+
+ Raises:
+ HTTPException: 404 if user not found
+ """
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.get(user_id)
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+ return user
+
+
+@auth_router.patch("/users/{user_id}", response_model=UserDTO)
+async def update_user(
+ user_id: Annotated[str, Path(description="User ID")],
+ request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")],
+ current_user: AdminUser,
+) -> UserDTO:
+ """Update a user. Requires admin privileges.
+
+ Args:
+ user_id: The user ID
+ request: Fields to update
+
+ Returns:
+ The updated user
+
+ Raises:
+ HTTPException: 400 if password is weak
+ HTTPException: 404 if user not found
+ """
+ user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
+ try:
+ changes = UserUpdateRequest(
+ display_name=request.display_name,
+ password=request.password,
+ is_admin=request.is_admin,
+ is_active=request.is_active,
+ )
+ return user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+
+
+@auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_user(
+ user_id: Annotated[str, Path(description="User ID")],
+ current_user: AdminUser,
+) -> None:
+ """Delete a user. Requires admin privileges.
+
+ Admins can delete any user including other admins, but cannot delete the last
+ remaining admin.
+
+ Args:
+ user_id: The user ID
+
+ Raises:
+ HTTPException: 400 if attempting to delete the last admin
+ HTTPException: 404 if user not found
+ """
+ user_service = ApiDependencies.invoker.services.users
+ user = user_service.get(user_id)
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+ # Prevent deleting the last active admin
+ if user.is_admin and user.is_active and user_service.count_admins() <= 1:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Cannot delete the last administrator",
+ )
+
+ try:
+ user_service.delete(user_id)
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
+
+
+@auth_router.patch("/me", response_model=UserDTO)
+async def update_current_user(
+ request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")],
+ current_user: CurrentUser,
+) -> UserDTO:
+ """Update the current user's own profile.
+
+ To change the password, both ``current_password`` and ``new_password`` must
+ be provided. The current password is verified before the change is applied.
+
+ Args:
+ request: Profile fields to update
+ current_user: The authenticated user
+
+ Returns:
+ The updated user
+
+ Raises:
+ HTTPException: 400 if current password is incorrect or new password is weak
+ HTTPException: 404 if user not found
+ """
+ user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
+
+ # Verify current password when attempting a password change
+ if request.new_password is not None:
+ if not request.current_password:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Current password is required to set a new password",
+ )
+
+ # Re-authenticate to verify the current password
+ user = user_service.get(current_user.user_id)
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+ authenticated = user_service.authenticate(user.email, request.current_password)
+ if authenticated is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Current password is incorrect",
+ )
+
+ try:
+ changes = UserUpdateRequest(
+ display_name=request.display_name,
+ password=request.new_password,
+ )
+ return user_service.update(
+ current_user.user_id, changes, strict_password_checking=config.strict_password_checking
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py
index 8e36a682d26..f94e4f2437c 100644
--- a/invokeai/app/api/routers/board_images.py
+++ b/invokeai/app/api/routers/board_images.py
@@ -1,19 +1,51 @@
from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
-from pydantic import BaseModel, Field
-from ..dependencies import ApiDependencies
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult
board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"])
-class AddImagesToBoardResult(BaseModel):
- board_id: str = Field(description="The id of the board the images were added to")
- added_image_names: list[str] = Field(description="The image names that were added to the board")
+def _assert_board_write_access(board_id: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user may not mutate the given board.
+ Write access is granted when ANY of these hold:
+ - The user is an admin.
+ - The user owns the board.
+ - The board visibility is Public (public boards accept contributions from any user).
+ """
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
-class RemoveImagesFromBoardResult(BaseModel):
- removed_image_names: list[str] = Field(description="The image names that were removed from their board")
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if current_user.is_admin:
+ return
+ if board.user_id == current_user.user_id:
+ return
+ if board.board_visibility == BoardVisibility.Public:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to modify this board")
+
+
+def _assert_image_direct_owner(image_name: str, current_user: CurrentUserOrDefault) -> None:
+ """Raise 403 if the current user is not the direct owner of the image.
+
+ This is intentionally stricter than _assert_image_owner in images.py:
+ board ownership is NOT sufficient here. Allowing a user to add someone
+ else's image to their own board would grant them mutation rights via the
+ board-ownership fallback in _assert_image_owner, escalating read access
+ into write access.
+ """
+ if current_user.is_admin:
+ return
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to move this image")
@board_images_router.post(
@@ -23,17 +55,29 @@ class RemoveImagesFromBoardResult(BaseModel):
201: {"description": "The image was added to a board successfully"},
},
status_code=201,
+ response_model=AddImagesToBoardResult,
)
async def add_image_to_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_name: str = Body(description="The name of the image to add"),
-):
+) -> AddImagesToBoardResult:
"""Creates a board_image"""
+ _assert_board_write_access(board_id, current_user)
+ _assert_image_direct_owner(image_name, current_user)
try:
- result = ApiDependencies.invoker.services.board_images.add_image_to_board(
- board_id=board_id, image_name=image_name
+ added_images: set[str] = set()
+ affected_boards: set[str] = set()
+ old_board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
+ ApiDependencies.invoker.services.board_images.add_image_to_board(board_id=board_id, image_name=image_name)
+ added_images.add(image_name)
+ affected_boards.add(board_id)
+ affected_boards.add(old_board_id)
+
+ return AddImagesToBoardResult(
+ added_images=list(added_images),
+ affected_boards=list(affected_boards),
)
- return result
except Exception:
raise HTTPException(status_code=500, detail="Failed to add image to board")
@@ -45,14 +89,30 @@ async def add_image_to_board(
201: {"description": "The image was removed from the board successfully"},
},
status_code=201,
+ response_model=RemoveImagesFromBoardResult,
)
async def remove_image_from_board(
+ current_user: CurrentUserOrDefault,
image_name: str = Body(description="The name of the image to remove", embed=True),
-):
+) -> RemoveImagesFromBoardResult:
"""Removes an image from its board, if it had one"""
try:
- result = ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
- return result
+ old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
+ if old_board_id != "none":
+ _assert_board_write_access(old_board_id, current_user)
+ removed_images: set[str] = set()
+ affected_boards: set[str] = set()
+ ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
+ removed_images.add(image_name)
+ affected_boards.add("none")
+ affected_boards.add(old_board_id)
+ return RemoveImagesFromBoardResult(
+ removed_images=list(removed_images),
+ affected_boards=list(affected_boards),
+ )
+
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove image from board")
@@ -67,21 +127,39 @@ async def remove_image_from_board(
response_model=AddImagesToBoardResult,
)
async def add_images_to_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Body(description="The id of the board to add to"),
image_names: list[str] = Body(description="The names of the images to add", embed=True),
) -> AddImagesToBoardResult:
"""Adds a list of images to a board"""
+ _assert_board_write_access(board_id, current_user)
try:
- added_image_names: list[str] = []
+ added_images: set[str] = set()
+ affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_direct_owner(image_name, current_user)
+ old_board_id = (
+ ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name) or "none"
+ )
ApiDependencies.invoker.services.board_images.add_image_to_board(
- board_id=board_id, image_name=image_name
+ board_id=board_id,
+ image_name=image_name,
)
- added_image_names.append(image_name)
+ added_images.add(image_name)
+ affected_boards.add(board_id)
+ affected_boards.add(old_board_id)
+
+ except HTTPException:
+ raise
except Exception:
pass
- return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names)
+ return AddImagesToBoardResult(
+ added_images=list(added_images),
+ affected_boards=list(affected_boards),
+ )
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to add images to board")
@@ -96,17 +174,31 @@ async def add_images_to_board(
response_model=RemoveImagesFromBoardResult,
)
async def remove_images_from_board(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The names of the images to remove", embed=True),
) -> RemoveImagesFromBoardResult:
"""Removes a list of images from their board, if they had one"""
try:
- removed_image_names: list[str] = []
+ removed_images: set[str] = set()
+ affected_boards: set[str] = set()
for image_name in image_names:
try:
+ old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none"
+ if old_board_id != "none":
+ _assert_board_write_access(old_board_id, current_user)
ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name)
- removed_image_names.append(image_name)
+ removed_images.add(image_name)
+ affected_boards.add("none")
+ affected_boards.add(old_board_id)
+ except HTTPException:
+ raise
except Exception:
pass
- return RemoveImagesFromBoardResult(removed_image_names=removed_image_names)
+ return RemoveImagesFromBoardResult(
+ removed_images=list(removed_images),
+ affected_boards=list(affected_boards),
+ )
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to remove images from board")
diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py
index 69f965da64a..6897e90aff4 100644
--- a/invokeai/app/api/routers/boards.py
+++ b/invokeai/app/api/routers/boards.py
@@ -4,11 +4,13 @@
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
-from invokeai.app.services.board_records.board_records_common import BoardChanges
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility
from invokeai.app.services.boards.boards_common import BoardDTO
+from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from ..dependencies import ApiDependencies
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
boards_router = APIRouter(prefix="/v1/boards", tags=["boards"])
@@ -31,11 +33,12 @@ class DeleteBoardResult(BaseModel):
response_model=BoardDTO,
)
async def create_board(
- board_name: str = Query(description="The name of the board to create"),
+ current_user: CurrentUserOrDefault,
+ board_name: str = Query(description="The name of the board to create", max_length=300),
) -> BoardDTO:
- """Creates a board"""
+ """Creates a board for the current user"""
try:
- result = ApiDependencies.invoker.services.boards.create(board_name=board_name)
+ result = ApiDependencies.invoker.services.boards.create(board_name=board_name, user_id=current_user.user_id)
return result
except Exception:
raise HTTPException(status_code=500, detail="Failed to create board")
@@ -43,16 +46,28 @@ async def create_board(
@boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO)
async def get_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to get"),
) -> BoardDTO:
- """Gets a board"""
+ """Gets a board (user must have access to it)"""
try:
result = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
- return result
except Exception:
raise HTTPException(status_code=404, detail="Board not found")
+ # Admins can access any board.
+ # Owners can access their own boards.
+ # Shared and public boards are visible to all authenticated users.
+ if (
+ not current_user.is_admin
+ and result.user_id != current_user.user_id
+ and result.board_visibility == BoardVisibility.Private
+ ):
+ raise HTTPException(status_code=403, detail="Not authorized to access this board")
+
+ return result
+
@boards_router.patch(
"/{board_id}",
@@ -66,10 +81,19 @@ async def get_board(
response_model=BoardDTO,
)
async def update_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to update"),
changes: BoardChanges = Body(description="The changes to apply to the board"),
) -> BoardDTO:
- """Updates a board"""
+ """Updates a board (user must have access to it)"""
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ if not current_user.is_admin and board.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this board")
+
try:
result = ApiDependencies.invoker.services.boards.update(board_id=board_id, changes=changes)
return result
@@ -79,14 +103,25 @@ async def update_board(
@boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult)
async def delete_board(
+ current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query(description="Permanently delete all images on the board", default=False),
) -> DeleteBoardResult:
- """Deletes a board"""
+ """Deletes a board (user must have access to it)"""
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ if not current_user.is_admin and board.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to delete this board")
+
try:
if include_images is True:
deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
- board_id=board_id
+ board_id=board_id,
+ categories=None,
+ is_intermediate=None,
)
ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
@@ -97,7 +132,9 @@ async def delete_board(
)
else:
deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
- board_id=board_id
+ board_id=board_id,
+ categories=None,
+ is_intermediate=None,
)
ApiDependencies.invoker.services.boards.delete(board_id=board_id)
return DeleteBoardResult(
@@ -115,17 +152,22 @@ async def delete_board(
response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]],
)
async def list_boards(
+ current_user: CurrentUserOrDefault,
+ order_by: BoardRecordOrderBy = Query(default=BoardRecordOrderBy.CreatedAt, description="The attribute to order by"),
+ direction: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The direction to order by"),
all: Optional[bool] = Query(default=None, description="Whether to list all boards"),
offset: Optional[int] = Query(default=None, description="The page offset"),
limit: Optional[int] = Query(default=None, description="The number of boards per page"),
+ include_archived: bool = Query(default=False, description="Whether or not to include archived boards in list"),
) -> Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]]:
- """Gets a list of boards"""
+ """Gets a list of boards for the current user, including shared boards. Admin users see all boards."""
if all:
- return ApiDependencies.invoker.services.boards.get_all()
+ return ApiDependencies.invoker.services.boards.get_all(
+ current_user.user_id, current_user.is_admin, order_by, direction, include_archived
+ )
elif offset is not None and limit is not None:
return ApiDependencies.invoker.services.boards.get_many(
- offset,
- limit,
+ current_user.user_id, current_user.is_admin, order_by, direction, offset, limit, include_archived
)
else:
raise HTTPException(
@@ -140,11 +182,40 @@ async def list_boards(
response_model=list[str],
)
async def list_all_board_image_names(
- board_id: str = Path(description="The id of the board"),
+ current_user: CurrentUserOrDefault,
+ board_id: str = Path(description="The id of the board or 'none' for uncategorized images"),
+ categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."),
+ is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."),
) -> list[str]:
"""Gets a list of images for a board"""
+ if board_id != "none":
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+
+ if (
+ not current_user.is_admin
+ and board.user_id != current_user.user_id
+ and board.board_visibility == BoardVisibility.Private
+ ):
+ raise HTTPException(status_code=403, detail="Not authorized to access this board")
+
image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
board_id,
+ categories,
+ is_intermediate,
)
+
+ # For uncategorized images (board_id="none"), filter to only the caller's
+ # images so that one user cannot enumerate another's uncategorized images.
+ # Admin users can see all uncategorized images.
+ if board_id == "none" and not current_user.is_admin:
+ image_names = [
+ name
+ for name in image_names
+ if ApiDependencies.invoker.services.image_records.get_user_id(name) == current_user.user_id
+ ]
+
return image_names
diff --git a/invokeai/app/api/routers/client_state.py b/invokeai/app/api/routers/client_state.py
new file mode 100644
index 00000000000..cd92263f97c
--- /dev/null
+++ b/invokeai/app/api/routers/client_state.py
@@ -0,0 +1,100 @@
+from fastapi import Body, HTTPException, Path, Query
+from fastapi.routing import APIRouter
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.backend.util.logging import logging
+
+client_state_router = APIRouter(prefix="/v1/client_state", tags=["client_state"])
+
+
+@client_state_router.get(
+ "/{queue_id}/get_by_key",
+ operation_id="get_client_state_by_key",
+ response_model=str | None,
+)
+async def get_client_state_by_key(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"),
+ key: str = Query(..., description="Key to get"),
+) -> str | None:
+ """Gets the client state for the current user (or system user if not authenticated)"""
+ try:
+ return ApiDependencies.invoker.services.client_state_persistence.get_by_key(current_user.user_id, key)
+ except Exception as e:
+ logging.error(f"Error getting client state: {e}")
+ raise HTTPException(status_code=500, detail="Error getting client state")
+
+
+@client_state_router.post(
+ "/{queue_id}/set_by_key",
+ operation_id="set_client_state",
+ response_model=str,
+)
+async def set_client_state(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"),
+ key: str = Query(..., description="Key to set"),
+ value: str = Body(..., description="Stringified value to set"),
+) -> str:
+ """Sets the client state for the current user (or system user if not authenticated)"""
+ try:
+ return ApiDependencies.invoker.services.client_state_persistence.set_by_key(current_user.user_id, key, value)
+ except Exception as e:
+ logging.error(f"Error setting client state: {e}")
+ raise HTTPException(status_code=500, detail="Error setting client state")
+
+
+@client_state_router.get(
+ "/{queue_id}/get_keys_by_prefix",
+ operation_id="get_client_state_keys_by_prefix",
+ response_model=list[str],
+)
+async def get_client_state_keys_by_prefix(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"),
+ prefix: str = Query(..., description="Prefix to filter keys by"),
+) -> list[str]:
+ """Gets client state keys matching a prefix for the current user"""
+ try:
+ return ApiDependencies.invoker.services.client_state_persistence.get_keys_by_prefix(
+ current_user.user_id, prefix
+ )
+ except Exception as e:
+ logging.error(f"Error getting client state keys: {e}")
+ raise HTTPException(status_code=500, detail="Error getting client state keys")
+
+
+@client_state_router.post(
+ "/{queue_id}/delete_by_key",
+ operation_id="delete_client_state_by_key",
+ responses={204: {"description": "Client state key deleted"}},
+)
+async def delete_client_state_by_key(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"),
+ key: str = Query(..., description="Key to delete"),
+) -> None:
+ """Deletes a specific client state key for the current user"""
+ try:
+ ApiDependencies.invoker.services.client_state_persistence.delete_by_key(current_user.user_id, key)
+ except Exception as e:
+ logging.error(f"Error deleting client state key: {e}")
+ raise HTTPException(status_code=500, detail="Error deleting client state key")
+
+
+@client_state_router.post(
+ "/{queue_id}/delete",
+ operation_id="delete_client_state",
+ responses={204: {"description": "Client state deleted"}},
+)
+async def delete_client_state(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"),
+) -> None:
+ """Deletes the client state for the current user (or system user if not authenticated)"""
+ try:
+ ApiDependencies.invoker.services.client_state_persistence.delete(current_user.user_id)
+ except Exception as e:
+ logging.error(f"Error deleting client state: {e}")
+ raise HTTPException(status_code=500, detail="Error deleting client state")
diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py
new file mode 100644
index 00000000000..3ee8c0ec99c
--- /dev/null
+++ b/invokeai/app/api/routers/custom_nodes.py
@@ -0,0 +1,504 @@
+"""FastAPI routes for custom node management."""
+
+import json
+import shutil
+import subprocess
+import sys
+import traceback
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+from typing import Optional
+
+from fastapi import Body
+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.invocations.baseinvocation import InvocationRegistry
+from invokeai.app.services.config.config_default import get_config
+from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator
+from invokeai.backend.util.logging import InvokeAILogger
+
+custom_nodes_router = APIRouter(prefix="/v2/custom_nodes", tags=["custom_nodes"])
+
+logger = InvokeAILogger.get_logger()
+
+# Name of the manifest file written inside a pack directory to track which workflows
+# were imported by that pack. Used on uninstall to delete only pack-imported workflows
+# — deleting by tag alone is unsafe because users can edit tags on their own workflows.
+PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json"
+
+
+class NodePackInfo(BaseModel):
+ """Information about an installed node pack."""
+
+ name: str = Field(description="The name of the node pack.")
+ path: str = Field(description="The path to the node pack directory.")
+ node_count: int = Field(description="The number of nodes in the pack.")
+ node_types: list[str] = Field(description="The invocation types provided by this node pack.")
+
+
+class NodePackListResponse(BaseModel):
+ """Response for listing installed node packs."""
+
+ node_packs: list[NodePackInfo] = Field(description="List of installed node packs.")
+ custom_nodes_path: str = Field(description="The configured custom nodes directory path.")
+
+
+class InstallNodePackRequest(BaseModel):
+ """Request to install a node pack from a git URL."""
+
+ source: str = Field(description="Git URL of the node pack to install.")
+
+
+class InstallNodePackResponse(BaseModel):
+ """Response after installing a node pack."""
+
+ name: str = Field(description="The name of the installed node pack.")
+ success: bool = Field(description="Whether the installation was successful.")
+ message: str = Field(description="Status message.")
+ workflows_imported: int = Field(default=0, description="Number of workflows imported from the pack.")
+ requires_dependencies: bool = Field(
+ default=False,
+ description="Whether the pack ships a dependency manifest (requirements.txt or pyproject.toml) "
+ "that the user must install manually following the pack's documentation.",
+ )
+ dependency_file: Optional[str] = Field(
+ default=None,
+ description="Name of the detected dependency manifest file, if any.",
+ )
+
+
+class UninstallNodePackResponse(BaseModel):
+ """Response after uninstalling a node pack."""
+
+ name: str = Field(description="The name of the uninstalled node pack.")
+ success: bool = Field(description="Whether the uninstall was successful.")
+ message: str = Field(description="Status message.")
+
+
+def _get_custom_nodes_path() -> Path:
+ """Returns the configured custom nodes directory path."""
+ config = get_config()
+ return config.custom_nodes_path
+
+
+def _get_installed_packs() -> list[NodePackInfo]:
+ """Scans the custom nodes directory and returns info about installed packs."""
+ custom_nodes_path = _get_custom_nodes_path()
+
+ if not custom_nodes_path.exists():
+ return []
+
+ packs: list[NodePackInfo] = []
+
+ # Get all node types grouped by node_pack
+ node_types_by_pack: dict[str, list[str]] = {}
+ for inv_class in InvocationRegistry._invocation_classes:
+ node_pack = inv_class.UIConfig.node_pack
+ inv_type = inv_class.get_type()
+ if node_pack not in node_types_by_pack:
+ node_types_by_pack[node_pack] = []
+ node_types_by_pack[node_pack].append(inv_type)
+
+ for d in sorted(custom_nodes_path.iterdir()):
+ if not d.is_dir():
+ continue
+ if d.name.startswith("_") or d.name.startswith("."):
+ continue
+ init = d / "__init__.py"
+ if not init.exists():
+ continue
+
+ pack_name = d.name
+ node_types = node_types_by_pack.get(pack_name, [])
+
+ packs.append(
+ NodePackInfo(
+ name=pack_name,
+ path=str(d),
+ node_count=len(node_types),
+ node_types=node_types,
+ )
+ )
+
+ return packs
+
+
+@custom_nodes_router.get(
+ "/",
+ operation_id="list_custom_node_packs",
+ response_model=NodePackListResponse,
+)
+async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse:
+ """Lists all installed custom node packs.
+
+ Admin-only: the response includes absolute filesystem paths, and non-admins have no
+ legitimate use for pack management data (install/uninstall/reload are also admin-only).
+ """
+ packs = _get_installed_packs()
+ return NodePackListResponse(node_packs=packs, custom_nodes_path=str(_get_custom_nodes_path()))
+
+
+@custom_nodes_router.post(
+ "/install",
+ operation_id="install_custom_node_pack",
+ response_model=InstallNodePackResponse,
+)
+async def install_custom_node_pack(
+ current_admin: AdminUserOrDefault,
+ request: InstallNodePackRequest = Body(description="The source URL to install from."),
+) -> InstallNodePackResponse:
+ """Installs a custom node pack from a git URL by cloning it into the nodes directory."""
+ custom_nodes_path = _get_custom_nodes_path()
+ custom_nodes_path.mkdir(parents=True, exist_ok=True)
+
+ source = request.source.strip()
+
+ # Extract pack name from URL
+ pack_name = source.rstrip("/").split("/")[-1]
+ if pack_name.endswith(".git"):
+ pack_name = pack_name[:-4]
+
+ target_dir = custom_nodes_path / pack_name
+
+ if target_dir.exists():
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Node pack '{pack_name}' already exists. Uninstall it first to reinstall.",
+ )
+
+ try:
+ # Clone the repository
+ result = subprocess.run(
+ ["git", "clone", source, str(target_dir)],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+
+ if result.returncode != 0:
+ # Clean up on failure
+ if target_dir.exists():
+ shutil.rmtree(target_dir)
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Git clone failed: {result.stderr.strip()}",
+ )
+
+ # Detect dependency manifests but do NOT install them automatically.
+ # The user is responsible for installing dependencies per the pack's documentation,
+ # since arbitrary pip installs can break the InvokeAI environment.
+ dependency_file: Optional[str] = None
+ for candidate in ("requirements.txt", "pyproject.toml"):
+ if (target_dir / candidate).exists():
+ dependency_file = candidate
+ logger.info(f"Node pack '{pack_name}' ships a {candidate}; user must install dependencies manually.")
+ break
+
+ # Check for __init__.py
+ init_file = target_dir / "__init__.py"
+ if not init_file.exists():
+ shutil.rmtree(target_dir)
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Node pack '{pack_name}' does not contain an __init__.py file.",
+ )
+
+ # Load the node pack at runtime
+ _load_node_pack(pack_name, target_dir)
+
+ # Import any workflows found in the pack, owned by the installing admin and shared with all users
+ imported_workflow_ids = _import_workflows_from_pack(target_dir, pack_name, owner_user_id=current_admin.user_id)
+ _write_pack_manifest(target_dir, imported_workflow_ids)
+ workflows_imported = len(imported_workflow_ids)
+ workflow_msg = f" Imported {workflows_imported} workflow(s)." if workflows_imported > 0 else ""
+ dependency_msg = (
+ f" This pack includes a {dependency_file} — install its dependencies manually following the pack's documentation."
+ if dependency_file
+ else ""
+ )
+
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=True,
+ message=f"Successfully installed node pack '{pack_name}'.{workflow_msg}{dependency_msg}",
+ workflows_imported=workflows_imported,
+ requires_dependencies=dependency_file is not None,
+ dependency_file=dependency_file,
+ )
+
+ except subprocess.TimeoutExpired:
+ if target_dir.exists():
+ shutil.rmtree(target_dir)
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message="Installation timed out.",
+ )
+ except Exception:
+ if target_dir.exists():
+ shutil.rmtree(target_dir)
+ error = traceback.format_exc()
+ logger.error(f"Failed to install node pack {pack_name}: {error}")
+ return InstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Installation failed: {error}",
+ )
+
+
+@custom_nodes_router.delete(
+ "/{pack_name}",
+ operation_id="uninstall_custom_node_pack",
+ response_model=UninstallNodePackResponse,
+)
+async def uninstall_custom_node_pack(
+ current_admin: AdminUserOrDefault,
+ pack_name: str,
+) -> UninstallNodePackResponse:
+ """Uninstalls a custom node pack by removing its directory.
+
+ Note: A restart is required for the node removal to take full effect.
+ Installed nodes from the pack will remain registered until restart.
+ """
+ custom_nodes_path = _get_custom_nodes_path()
+ target_dir = custom_nodes_path / pack_name
+
+ if not target_dir.exists():
+ return UninstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Node pack '{pack_name}' not found.",
+ )
+
+ try:
+ # Read the manifest BEFORE removing the directory — it records exactly which
+ # workflow IDs this pack imported, so uninstall doesn't accidentally delete
+ # user workflows that happen to share the pack tag.
+ imported_workflow_ids = _read_pack_manifest(target_dir)
+
+ shutil.rmtree(target_dir)
+
+ # Unregister the nodes from the registry so they disappear immediately
+ removed_types = InvocationRegistry.unregister_pack(pack_name)
+ if removed_types:
+ # Invalidate OpenAPI schema cache so frontend gets updated node definitions
+ from invokeai.app.api_app import app
+
+ app.openapi_schema = None
+ logger.info(
+ f"Unregistered {len(removed_types)} node(s) from pack '{pack_name}': {', '.join(removed_types)}"
+ )
+
+ # Remove the pack's module subtree from sys.modules. Only dropping the
+ # root module would leave submodules cached; on reinstall the cached
+ # submodules would be reused without re-running their @invocation
+ # decorators, so the pack would show up with 0 nodes until restart.
+ _purge_pack_modules(pack_name)
+
+ # Remove only workflows this pack imported, using the manifest-recorded IDs
+ workflows_removed = _remove_workflows_by_ids(imported_workflow_ids, pack_name)
+ workflow_msg = f" Removed {workflows_removed} workflow(s)." if workflows_removed > 0 else ""
+
+ return UninstallNodePackResponse(
+ name=pack_name,
+ success=True,
+ message=f"Successfully uninstalled node pack '{pack_name}'.{workflow_msg}",
+ )
+ except Exception:
+ error = traceback.format_exc()
+ logger.error(f"Failed to uninstall node pack {pack_name}: {error}")
+ return UninstallNodePackResponse(
+ name=pack_name,
+ success=False,
+ message=f"Uninstall failed: {error}",
+ )
+
+
+@custom_nodes_router.post(
+ "/reload",
+ operation_id="reload_custom_nodes",
+)
+async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]:
+ """Triggers a reload of all custom nodes.
+
+ This re-scans the nodes directory and loads any new node packs.
+ Already loaded packs are skipped.
+ """
+ config = get_config()
+ custom_nodes_path = config.custom_nodes_path
+
+ if not custom_nodes_path.exists():
+ return {"status": "No custom nodes directory found."}
+
+ from invokeai.app.invocations.load_custom_nodes import load_custom_nodes
+
+ load_custom_nodes(custom_nodes_path, logger)
+
+ # Invalidate the OpenAPI schema cache so the frontend gets updated node definitions
+ from invokeai.app.api_app import app
+
+ app.openapi_schema = None
+
+ return {"status": "Custom nodes reloaded successfully."}
+
+
+def _purge_pack_modules(pack_name: str) -> list[str]:
+ """Removes the pack's root module and all of its submodules from sys.modules.
+
+ After uninstall, cached submodules (e.g. `pack_name.nodes`, `pack_name.foo.bar`)
+ must be evicted as well — otherwise a subsequent reinstall reuses the cached
+ objects, the @invocation decorators never re-run, and the pack ends up loaded
+ with zero registered nodes until a full process restart.
+ """
+ prefix = f"{pack_name}."
+ to_remove = [name for name in sys.modules if name == pack_name or name.startswith(prefix)]
+ for name in to_remove:
+ del sys.modules[name]
+ return to_remove
+
+
+def _load_node_pack(pack_name: str, pack_dir: Path) -> None:
+ """Loads a single node pack at runtime."""
+ init = pack_dir / "__init__.py"
+ if not init.exists():
+ return
+
+ if pack_name in sys.modules:
+ logger.info(f"Node pack {pack_name} already loaded, skipping.")
+ return
+
+ spec = spec_from_file_location(pack_name, init.absolute())
+ if spec is None or spec.loader is None:
+ logger.warning(f"Could not load {init}")
+ return
+
+ logger.info(f"Loading node pack {pack_name}")
+ module = module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+
+ # Invalidate OpenAPI schema cache
+ from invokeai.app.api_app import app
+
+ app.openapi_schema = None
+
+ logger.info(f"Successfully loaded node pack {pack_name}")
+
+
+def _import_workflows_from_pack(pack_dir: Path, pack_name: str, owner_user_id: str) -> list[str]:
+ """Scans a node pack directory for workflow JSON files and imports them into the workflow library.
+
+ A JSON file is considered a workflow if it contains 'nodes' and 'edges' keys at the top level.
+ Workflows are imported as user workflows owned by the installing admin and marked public so all
+ users can see them — a pack is an admin-installed shared resource, not a private asset.
+
+ Returns the list of workflow IDs successfully created, in import order.
+ """
+ imported_ids: list[str] = []
+
+ # Search for .json files recursively
+ for json_file in pack_dir.rglob("*.json"):
+ # Skip our own manifest file
+ if json_file.name == PACK_MANIFEST_FILENAME:
+ continue
+ try:
+ with open(json_file, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ # Check if this looks like a workflow (must have nodes and edges)
+ if not isinstance(data, dict):
+ continue
+ if "nodes" not in data or "edges" not in data:
+ continue
+
+ # Ensure the workflow has a meta section with category set to "user"
+ if "meta" not in data:
+ data["meta"] = {"version": "3.0.0", "category": "user"}
+ else:
+ data["meta"]["category"] = "user"
+
+ # Add the node pack name to tags for discoverability (display only — uninstall
+ # does not rely on this tag, since users can edit tags on their own workflows).
+ existing_tags = data.get("tags", "")
+ pack_tag = f"node-pack:{pack_name}"
+ if pack_tag not in existing_tags:
+ data["tags"] = f"{existing_tags}, {pack_tag}".strip(", ") if existing_tags else pack_tag
+
+ # Remove the 'id' field if present — the system will assign a new one
+ data.pop("id", None)
+
+ # Validate and import the workflow
+ workflow = WorkflowWithoutIDValidator.validate_python(data)
+ created = ApiDependencies.invoker.services.workflow_records.create(
+ workflow=workflow, user_id=owner_user_id, is_public=True
+ )
+ imported_ids.append(created.workflow_id)
+ logger.info(f"Imported workflow '{workflow.name}' from node pack '{pack_name}'")
+
+ except Exception:
+ logger.warning(f"Skipped non-workflow or invalid JSON file: {json_file}")
+ continue
+
+ if imported_ids:
+ logger.info(f"Imported {len(imported_ids)} workflow(s) from node pack '{pack_name}'")
+
+ return imported_ids
+
+
+def _write_pack_manifest(pack_dir: Path, workflow_ids: list[str]) -> None:
+ """Writes the pack manifest recording which workflow IDs were imported from the pack."""
+ manifest_path = pack_dir / PACK_MANIFEST_FILENAME
+ try:
+ with open(manifest_path, "w", encoding="utf-8") as f:
+ json.dump({"workflow_ids": workflow_ids}, f)
+ except Exception:
+ logger.warning(f"Failed to write pack manifest at {manifest_path}")
+
+
+def _read_pack_manifest(pack_dir: Path) -> list[str]:
+ """Reads workflow IDs that this pack's install recorded in its manifest.
+
+ Returns an empty list if the manifest is missing or malformed. We deliberately do NOT
+ fall back to tag-based lookup: workflow tags are user-editable and could collide with
+ unrelated workflows, so we only delete what we recorded ourselves at install time.
+ """
+ manifest_path = pack_dir / PACK_MANIFEST_FILENAME
+ if not manifest_path.exists():
+ return []
+ try:
+ with open(manifest_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ ids = data.get("workflow_ids", [])
+ if not isinstance(ids, list):
+ return []
+ return [str(x) for x in ids if isinstance(x, str)]
+ except Exception:
+ logger.warning(f"Failed to read pack manifest at {manifest_path}")
+ return []
+
+
+def _remove_workflows_by_ids(workflow_ids: list[str], pack_name: str) -> int:
+ """Deletes the given workflow IDs. Used during uninstall to remove only the workflows
+ this pack's install recorded in its manifest.
+ """
+ if not workflow_ids:
+ return 0
+
+ removed_count = 0
+ for workflow_id in workflow_ids:
+ try:
+ ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
+ removed_count += 1
+ except Exception:
+ logger.warning(f"Failed to remove workflow '{workflow_id}' (from node pack '{pack_name}')")
+
+ if removed_count > 0:
+ logger.info(f"Removed {removed_count} workflow(s) from node pack '{pack_name}'")
+
+ return removed_count
diff --git a/invokeai/app/api/routers/download_queue.py b/invokeai/app/api/routers/download_queue.py
index a6e53c7a5c4..305eaf9273e 100644
--- a/invokeai/app/api/routers/download_queue.py
+++ b/invokeai/app/api/routers/download_queue.py
@@ -1,6 +1,8 @@
# Copyright (c) 2023 Lincoln D. Stein
"""FastAPI route for the download queue."""
+from pathlib import Path as FsPath
+from pathlib import PurePosixPath, PureWindowsPath
from typing import List, Optional
from fastapi import Body, Path, Response
@@ -8,21 +10,42 @@
from pydantic.networks import AnyHttpUrl
from starlette.exceptions import HTTPException
+from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.download import (
DownloadJob,
UnknownJobIDException,
)
-from ..dependencies import ApiDependencies
-
download_queue_router = APIRouter(prefix="/v1/download_queue", tags=["download_queue"])
+def _validate_dest(dest: str) -> str:
+ """Reject absolute paths and parent-traversal segments.
+
+ Accepts a relative POSIX- or Windows-style path. Returns the original string
+ for the caller to wrap in `Path(...)`. Raises 400 on suspicious input so the
+ download service never sees it.
+ """
+ if not dest or not dest.strip():
+ raise HTTPException(status_code=400, detail="Download destination must not be empty.")
+
+ posix = PurePosixPath(dest)
+ windows = PureWindowsPath(dest)
+ if posix.is_absolute() or windows.is_absolute():
+ raise HTTPException(status_code=400, detail="Download destination must be a relative path.")
+
+ if ".." in posix.parts or ".." in windows.parts:
+ raise HTTPException(status_code=400, detail="Download destination must not contain '..' segments.")
+
+ return dest
+
+
@download_queue_router.get(
"/",
operation_id="list_downloads",
)
-async def list_downloads() -> List[DownloadJob]:
+async def list_downloads(current_user: CurrentUserOrDefault) -> List[DownloadJob]:
"""Get a list of active and inactive jobs."""
queue = ApiDependencies.invoker.services.download_queue
return queue.list_jobs()
@@ -36,7 +59,7 @@ async def list_downloads() -> List[DownloadJob]:
400: {"description": "Bad request"},
},
)
-async def prune_downloads() -> Response:
+async def prune_downloads(current_user: AdminUserOrDefault) -> Response:
"""Prune completed and errored jobs."""
queue = ApiDependencies.invoker.services.download_queue
queue.prune_jobs()
@@ -48,14 +71,16 @@ async def prune_downloads() -> Response:
operation_id="download",
)
async def download(
+ current_user: CurrentUserOrDefault,
source: AnyHttpUrl = Body(description="download source"),
dest: str = Body(description="download destination"),
priority: int = Body(default=10, description="queue priority"),
access_token: Optional[str] = Body(default=None, description="token for authorization to download"),
) -> DownloadJob:
"""Download the source URL to the file or directory indicted in dest."""
+ validated_dest = _validate_dest(dest)
queue = ApiDependencies.invoker.services.download_queue
- return queue.download(source, Path(dest), priority, access_token)
+ return queue.download(source, FsPath(validated_dest), priority, access_token)
@download_queue_router.get(
@@ -67,6 +92,7 @@ async def download(
},
)
async def get_download_job(
+ current_user: CurrentUserOrDefault,
id: int = Path(description="ID of the download job to fetch."),
) -> DownloadJob:
"""Get a download job using its ID."""
@@ -86,6 +112,7 @@ async def get_download_job(
},
)
async def cancel_download_job(
+ current_user: CurrentUserOrDefault,
id: int = Path(description="ID of the download job to cancel."),
) -> Response:
"""Cancel a download job using its ID."""
@@ -105,7 +132,7 @@ async def cancel_download_job(
204: {"description": "Download jobs have been cancelled"},
},
)
-async def cancel_all_download_jobs() -> Response:
+async def cancel_all_download_jobs(current_user: AdminUserOrDefault) -> Response:
"""Cancel all download jobs."""
ApiDependencies.invoker.services.download_queue.cancel_all_jobs()
return Response(status_code=204)
diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py
index a947b83abe9..976434c68f2 100644
--- a/invokeai/app/api/routers/images.py
+++ b/invokeai/app/api/routers/images.py
@@ -1,19 +1,44 @@
import io
+import json
import traceback
-from typing import Optional
+from typing import ClassVar, Optional
from fastapi import BackgroundTasks, Body, HTTPException, Path, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from fastapi.routing import APIRouter
from PIL import Image
-from pydantic import BaseModel, Field, JsonValue
+from pydantic import BaseModel, Field, model_validator
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_image
+from invokeai.app.api.routers._access import (
+ assert_board_read_access as _assert_board_read_access,
+)
+from invokeai.app.api.routers._access import (
+ assert_image_owner as _assert_image_owner,
+)
+from invokeai.app.api.routers._access import (
+ assert_image_read_access as _assert_image_read_access,
+)
from invokeai.app.invocations.fields import MetadataField
-from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageRecordChanges, ResourceOrigin
-from invokeai.app.services.images.images_common import ImageDTO, ImageUrlsDTO
+from invokeai.app.services.image_records.image_records_common import (
+ ImageCategory,
+ ImageNamesResult,
+ ImageRecordChanges,
+ ResourceOrigin,
+)
+from invokeai.app.services.images.images_common import (
+ DeleteImagesResult,
+ ImageDTO,
+ ImageUrlsDTO,
+ StarredImagesResult,
+ UnstarredImagesResult,
+)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from ..dependencies import ApiDependencies
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.util.controlnet_utils import heuristic_resize_fast
+from invokeai.backend.image_util.util import np_to_pil, pil_to_np
images_router = APIRouter(prefix="/v1/images", tags=["images"])
@@ -22,6 +47,19 @@
IMAGE_MAX_AGE = 31536000
+class ResizeToDimensions(BaseModel):
+ width: int = Field(..., gt=0)
+ height: int = Field(..., gt=0)
+
+ MAX_SIZE: ClassVar[int] = 4096 * 4096
+
+ @model_validator(mode="after")
+ def validate_total_output_size(self):
+ if self.width * self.height > self.MAX_SIZE:
+ raise ValueError(f"Max total output size for resizing is {self.MAX_SIZE} pixels")
+ return self
+
+
@images_router.post(
"/upload",
operation_id="upload_image",
@@ -33,6 +71,7 @@
response_model=ImageDTO,
)
async def upload_image(
+ current_user: CurrentUserOrDefault,
file: UploadFile,
request: Request,
response: Response,
@@ -41,52 +80,74 @@ async def upload_image(
board_id: Optional[str] = Query(default=None, description="The board to add this image to, if any"),
session_id: Optional[str] = Query(default=None, description="The session ID associated with this upload, if any"),
crop_visible: Optional[bool] = Query(default=False, description="Whether to crop the image"),
- metadata: Optional[JsonValue] = Body(
- default=None, description="The metadata to associate with the image", embed=True
+ resize_to: Optional[str] = Body(
+ default=None,
+ description=f"Dimensions to resize the image to, must be stringified tuple of 2 integers. Max total pixel count: {ResizeToDimensions.MAX_SIZE}",
+ examples=['"[1024,1024]"'],
+ ),
+ metadata: Optional[str] = Body(
+ default=None,
+ description="The metadata to associate with the image, must be a stringified JSON dict",
+ embed=True,
),
) -> ImageDTO:
- """Uploads an image"""
+ """Uploads an image for the current user"""
+ # If uploading into a board, verify the user has write access.
+ # Public boards allow uploads from any authenticated user.
+ if board_id is not None:
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ except Exception:
+ raise HTTPException(status_code=404, detail="Board not found")
+ if (
+ not current_user.is_admin
+ and board.user_id != current_user.user_id
+ and board.board_visibility != BoardVisibility.Public
+ ):
+ raise HTTPException(status_code=403, detail="Not authorized to upload to this board")
+
if not file.content_type or not file.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
- _metadata = None
- _workflow = None
- _graph = None
-
contents = await file.read()
try:
pil_image = Image.open(io.BytesIO(contents))
- if crop_visible:
- bbox = pil_image.getbbox()
- pil_image = pil_image.crop(bbox)
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
- # TODO: retain non-invokeai metadata on upload?
- # attempt to parse metadata from image
- metadata_raw = metadata if isinstance(metadata, str) else pil_image.info.get("invokeai_metadata", None)
- if isinstance(metadata_raw, str):
- _metadata = metadata_raw
- else:
- ApiDependencies.invoker.services.logger.debug("Failed to parse metadata for uploaded image")
- pass
-
- # attempt to parse workflow from image
- workflow_raw = pil_image.info.get("invokeai_workflow", None)
- if isinstance(workflow_raw, str):
- _workflow = workflow_raw
- else:
- ApiDependencies.invoker.services.logger.debug("Failed to parse workflow for uploaded image")
- pass
-
- # attempt to extract graph from image
- graph_raw = pil_image.info.get("invokeai_graph", None)
- if isinstance(graph_raw, str):
- _graph = graph_raw
- else:
- ApiDependencies.invoker.services.logger.debug("Failed to parse graph for uploaded image")
- pass
+ if crop_visible:
+ try:
+ bbox = pil_image.getbbox()
+ pil_image = pil_image.crop(bbox)
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to crop image")
+
+ if resize_to:
+ try:
+ dims = json.loads(resize_to)
+ resize_dims = ResizeToDimensions(**dims)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid resize_to format or size")
+
+ try:
+ # heuristic_resize_fast expects an RGB or RGBA image
+ pil_rgba = pil_image.convert("RGBA")
+ np_image = pil_to_np(pil_rgba)
+ np_image = heuristic_resize_fast(np_image, (resize_dims.width, resize_dims.height))
+ pil_image = np_to_pil(np_image)
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to resize image")
+
+ extracted_metadata = extract_metadata_from_image(
+ pil_image=pil_image,
+ invokeai_metadata_override=metadata,
+ invokeai_workflow_override=None,
+ invokeai_graph_override=None,
+ logger=ApiDependencies.invoker.services.logger,
+ )
try:
image_dto = ApiDependencies.invoker.services.images.create(
@@ -95,10 +156,11 @@ async def upload_image(
image_category=image_category,
session_id=session_id,
board_id=board_id,
- metadata=_metadata,
- workflow=_workflow,
- graph=_graph,
+ metadata=extracted_metadata.invokeai_metadata,
+ workflow=extracted_metadata.invokeai_workflow,
+ graph=extracted_metadata.invokeai_graph,
is_intermediate=is_intermediate,
+ user_id=current_user.user_id,
)
response.status_code = 201
@@ -110,40 +172,75 @@ async def upload_image(
raise HTTPException(status_code=500, detail="Failed to create image")
-@images_router.delete("/i/{image_name}", operation_id="delete_image")
+class ImageUploadEntry(BaseModel):
+ image_dto: ImageDTO = Body(description="The image DTO")
+ presigned_url: str = Body(description="The URL to get the presigned URL for the image upload")
+
+
+@images_router.post("/", operation_id="create_image_upload_entry")
+async def create_image_upload_entry(
+ width: int = Body(description="The width of the image"),
+ height: int = Body(description="The height of the image"),
+ board_id: Optional[str] = Body(default=None, description="The board to add this image to, if any"),
+) -> ImageUploadEntry:
+ """Uploads an image from a URL, not implemented"""
+
+ raise HTTPException(status_code=501, detail="Not implemented")
+
+
+@images_router.delete("/i/{image_name}", operation_id="delete_image", response_model=DeleteImagesResult)
async def delete_image(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image to delete"),
-) -> None:
+) -> DeleteImagesResult:
"""Deletes an image"""
+ _assert_image_owner(image_name, current_user)
+
+ deleted_images: set[str] = set()
+ affected_boards: set[str] = set()
try:
+ image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
+ board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
+ deleted_images.add(image_name)
+ affected_boards.add(board_id)
except Exception:
# TODO: Does this need any exception handling at all?
pass
+ return DeleteImagesResult(
+ deleted_images=list(deleted_images),
+ affected_boards=list(affected_boards),
+ )
+
@images_router.delete("/intermediates", operation_id="clear_intermediates")
-async def clear_intermediates() -> int:
- """Clears all intermediates"""
+async def clear_intermediates(
+ current_user: CurrentUserOrDefault,
+) -> int:
+ """Clears all intermediates. Requires admin."""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Only admins can clear all intermediates")
try:
count_deleted = ApiDependencies.invoker.services.images.delete_intermediates()
return count_deleted
except Exception:
raise HTTPException(status_code=500, detail="Failed to clear intermediates")
- pass
@images_router.get("/intermediates", operation_id="get_intermediates_count")
-async def get_intermediates_count() -> int:
- """Gets the count of intermediate images"""
+async def get_intermediates_count(
+ current_user: CurrentUserOrDefault,
+) -> int:
+ """Gets the count of intermediate images. Non-admin users only see their own intermediates."""
try:
- return ApiDependencies.invoker.services.images.get_intermediates_count()
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.images.get_intermediates_count(user_id=user_id)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get intermediates")
- pass
@images_router.patch(
@@ -152,10 +249,12 @@ async def get_intermediates_count() -> int:
response_model=ImageDTO,
)
async def update_image(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image to update"),
image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"),
) -> ImageDTO:
"""Updates an image"""
+ _assert_image_owner(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.update(image_name, image_changes)
@@ -169,9 +268,11 @@ async def update_image(
response_model=ImageDTO,
)
async def get_image_dto(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image to get"),
) -> ImageDTO:
"""Gets an image's DTO"""
+ _assert_image_read_access(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.get_dto(image_name)
@@ -185,9 +286,11 @@ async def get_image_dto(
response_model=Optional[MetadataField],
)
async def get_image_metadata(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image to get"),
) -> Optional[MetadataField]:
"""Gets an image's metadata"""
+ _assert_image_read_access(image_name, current_user)
try:
return ApiDependencies.invoker.services.images.get_metadata(image_name)
@@ -204,8 +307,11 @@ class WorkflowAndGraphResponse(BaseModel):
"/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse
)
async def get_image_workflow(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of image whose workflow to get"),
) -> WorkflowAndGraphResponse:
+ _assert_image_read_access(image_name, current_user)
+
try:
workflow = ApiDependencies.invoker.services.images.get_workflow(image_name)
graph = ApiDependencies.invoker.services.images.get_graph(image_name)
@@ -214,9 +320,8 @@ async def get_image_workflow(
raise HTTPException(status_code=404)
-@images_router.api_route(
+@images_router.get(
"/i/{image_name}/full",
- methods=["GET", "HEAD"],
operation_id="get_image_full",
response_class=Response,
responses={
@@ -227,24 +332,34 @@ async def get_image_workflow(
404: {"description": "Image not found"},
},
)
+@images_router.head(
+ "/i/{image_name}/full",
+ operation_id="get_image_full_head",
+ response_class=Response,
+ responses={
+ 200: {
+ "description": "Return the full-resolution image",
+ "content": {"image/png": {}},
+ },
+ 404: {"description": "Image not found"},
+ },
+)
async def get_image_full(
image_name: str = Path(description="The name of full-resolution image file to get"),
-) -> FileResponse:
- """Gets a full-resolution image file"""
+) -> Response:
+ """Gets a full-resolution image file.
+ This endpoint is intentionally unauthenticated because browsers load images
+ via tags which cannot send Bearer tokens. Image names are UUIDs,
+ providing security through unguessability.
+ """
try:
path = ApiDependencies.invoker.services.images.get_path(image_name)
-
- if not ApiDependencies.invoker.services.images.validate_path(path):
- raise HTTPException(status_code=404)
-
- response = FileResponse(
- path,
- media_type="image/png",
- filename=image_name,
- content_disposition_type="inline",
- )
+ with open(path, "rb") as f:
+ content = f.read()
+ response = Response(content, media_type="image/png")
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
+ response.headers["Content-Disposition"] = f'inline; filename="{image_name}"'
return response
except Exception:
raise HTTPException(status_code=404)
@@ -264,15 +379,18 @@ async def get_image_full(
)
async def get_image_thumbnail(
image_name: str = Path(description="The name of thumbnail image file to get"),
-) -> FileResponse:
- """Gets a thumbnail image file"""
+) -> Response:
+ """Gets a thumbnail image file.
+ This endpoint is intentionally unauthenticated because browsers load images
+ via tags which cannot send Bearer tokens. Image names are UUIDs,
+ providing security through unguessability.
+ """
try:
path = ApiDependencies.invoker.services.images.get_path(image_name, thumbnail=True)
- if not ApiDependencies.invoker.services.images.validate_path(path):
- raise HTTPException(status_code=404)
-
- response = FileResponse(path, media_type="image/webp", content_disposition_type="inline")
+ with open(path, "rb") as f:
+ content = f.read()
+ response = Response(content, media_type="image/webp")
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
return response
except Exception:
@@ -285,9 +403,11 @@ async def get_image_thumbnail(
response_model=ImageUrlsDTO,
)
async def get_image_urls(
+ current_user: CurrentUserOrDefault,
image_name: str = Path(description="The name of the image whose URL to get"),
) -> ImageUrlsDTO:
"""Gets an image and thumbnail URL"""
+ _assert_image_read_access(image_name, current_user)
try:
image_url = ApiDependencies.invoker.services.images.get_url(image_name)
@@ -307,6 +427,7 @@ async def get_image_urls(
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_image_dtos(
+ current_user: CurrentUserOrDefault,
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
@@ -316,38 +437,91 @@ async def list_image_dtos(
),
offset: int = Query(default=0, description="The page offset"),
limit: int = Query(default=10, description="The number of images per page"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred images first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
) -> OffsetPaginatedResults[ImageDTO]:
- """Gets a list of image DTOs"""
+ """Gets a list of image DTOs for the current user"""
+
+ # Validate that the caller can read from this board before listing its images.
+ # "none" is a sentinel for uncategorized images and is handled by the SQL layer.
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
image_dtos = ApiDependencies.invoker.services.images.get_many(
offset,
limit,
+ starred_first,
+ order_dir,
image_origin,
categories,
is_intermediate,
board_id,
+ search_term,
+ current_user.user_id,
)
return image_dtos
-class DeleteImagesFromListResult(BaseModel):
- deleted_images: list[str]
-
-
-@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult)
+@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesResult)
async def delete_images_from_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to delete", embed=True),
-) -> DeleteImagesFromListResult:
+) -> DeleteImagesResult:
try:
- deleted_images: list[str] = []
+ deleted_images: set[str] = set()
+ affected_boards: set[str] = set()
for image_name in image_names:
try:
+ _assert_image_owner(image_name, current_user)
+ image_dto = ApiDependencies.invoker.services.images.get_dto(image_name)
+ board_id = image_dto.board_id or "none"
ApiDependencies.invoker.services.images.delete(image_name)
- deleted_images.append(image_name)
+ deleted_images.add(image_name)
+ affected_boards.add(board_id)
+ except HTTPException:
+ raise
except Exception:
pass
- return DeleteImagesFromListResult(deleted_images=deleted_images)
+ return DeleteImagesResult(
+ deleted_images=list(deleted_images),
+ affected_boards=list(affected_boards),
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to delete images")
+
+
+@images_router.delete("/uncategorized", operation_id="delete_uncategorized_images", response_model=DeleteImagesResult)
+async def delete_uncategorized_images(
+ current_user: CurrentUserOrDefault,
+) -> DeleteImagesResult:
+ """Deletes all uncategorized images owned by the current user (or all if admin)"""
+
+ image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board(
+ board_id="none", categories=None, is_intermediate=None
+ )
+
+ try:
+ deleted_images: set[str] = set()
+ affected_boards: set[str] = set()
+ for image_name in image_names:
+ try:
+ _assert_image_owner(image_name, current_user)
+ ApiDependencies.invoker.services.images.delete(image_name)
+ deleted_images.add(image_name)
+ affected_boards.add("none")
+ except HTTPException:
+ # Skip images not owned by the current user
+ pass
+ except Exception:
+ pass
+ return DeleteImagesResult(
+ deleted_images=list(deleted_images),
+ affected_boards=list(affected_boards),
+ )
except Exception:
raise HTTPException(status_code=500, detail="Failed to delete images")
@@ -356,36 +530,62 @@ class ImagesUpdatedFromListResult(BaseModel):
updated_image_names: list[str] = Field(description="The image names that were updated")
-@images_router.post("/star", operation_id="star_images_in_list", response_model=ImagesUpdatedFromListResult)
+@images_router.post("/star", operation_id="star_images_in_list", response_model=StarredImagesResult)
async def star_images_in_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to star", embed=True),
-) -> ImagesUpdatedFromListResult:
+) -> StarredImagesResult:
try:
- updated_image_names: list[str] = []
+ starred_images: set[str] = set()
+ affected_boards: set[str] = set()
for image_name in image_names:
try:
- ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True))
- updated_image_names.append(image_name)
+ _assert_image_owner(image_name, current_user)
+ updated_image_dto = ApiDependencies.invoker.services.images.update(
+ image_name, changes=ImageRecordChanges(starred=True)
+ )
+ starred_images.add(image_name)
+ affected_boards.add(updated_image_dto.board_id or "none")
+ except HTTPException:
+ raise
except Exception:
pass
- return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
+ return StarredImagesResult(
+ starred_images=list(starred_images),
+ affected_boards=list(affected_boards),
+ )
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to star images")
-@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=ImagesUpdatedFromListResult)
+@images_router.post("/unstar", operation_id="unstar_images_in_list", response_model=UnstarredImagesResult)
async def unstar_images_in_list(
+ current_user: CurrentUserOrDefault,
image_names: list[str] = Body(description="The list of names of images to unstar", embed=True),
-) -> ImagesUpdatedFromListResult:
+) -> UnstarredImagesResult:
try:
- updated_image_names: list[str] = []
+ unstarred_images: set[str] = set()
+ affected_boards: set[str] = set()
for image_name in image_names:
try:
- ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False))
- updated_image_names.append(image_name)
+ _assert_image_owner(image_name, current_user)
+ updated_image_dto = ApiDependencies.invoker.services.images.update(
+ image_name, changes=ImageRecordChanges(starred=False)
+ )
+ unstarred_images.add(image_name)
+ affected_boards.add(updated_image_dto.board_id or "none")
+ except HTTPException:
+ raise
except Exception:
pass
- return ImagesUpdatedFromListResult(updated_image_names=updated_image_names)
+ return UnstarredImagesResult(
+ unstarred_images=list(unstarred_images),
+ affected_boards=list(affected_boards),
+ )
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=500, detail="Failed to unstar images")
@@ -403,6 +603,7 @@ class ImagesDownloaded(BaseModel):
"/download", operation_id="download_images_from_list", response_model=ImagesDownloaded, status_code=202
)
async def download_images_from_list(
+ current_user: CurrentUserOrDefault,
background_tasks: BackgroundTasks,
image_names: Optional[list[str]] = Body(
default=None, description="The list of names of images to download", embed=True
@@ -413,6 +614,16 @@ async def download_images_from_list(
) -> ImagesDownloaded:
if (image_names is None or len(image_names) == 0) and board_id is None:
raise HTTPException(status_code=400, detail="No images or board id specified.")
+
+ # Validate that the caller can read every image they are requesting.
+ # For a board_id request, check board visibility; for explicit image names,
+ # check each image individually.
+ if board_id:
+ _assert_board_read_access(board_id, current_user)
+ if image_names:
+ for name in image_names:
+ _assert_image_read_access(name, current_user)
+
bulk_download_item_id: str = ApiDependencies.invoker.services.bulk_download.generate_item_id(board_id)
background_tasks.add_task(
@@ -420,6 +631,7 @@ async def download_images_from_list(
image_names,
board_id,
bulk_download_item_id,
+ current_user.user_id,
)
return ImagesDownloaded(bulk_download_item_name=bulk_download_item_id + ".zip")
@@ -438,11 +650,21 @@ async def download_images_from_list(
},
)
async def get_bulk_download_item(
+ current_user: CurrentUserOrDefault,
background_tasks: BackgroundTasks,
bulk_download_item_name: str = Path(description="The bulk_download_item_name of the bulk download item to get"),
) -> FileResponse:
- """Gets a bulk download zip file"""
+ """Gets a bulk download zip file.
+
+ Requires authentication. The caller must be the user who initiated the
+ download (tracked by the bulk download service) or an admin.
+ """
try:
+ # Verify the caller owns this download (or is an admin)
+ owner = ApiDependencies.invoker.services.bulk_download.get_owner(bulk_download_item_name)
+ if owner is not None and owner != current_user.user_id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Not authorized to access this download")
+
path = ApiDependencies.invoker.services.bulk_download.get_path(bulk_download_item_name)
response = FileResponse(
@@ -454,5 +676,77 @@ async def get_bulk_download_item(
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
background_tasks.add_task(ApiDependencies.invoker.services.bulk_download.delete, bulk_download_item_name)
return response
+ except HTTPException:
+ raise
except Exception:
raise HTTPException(status_code=404)
+
+
+@images_router.get("/names", operation_id="get_image_names")
+async def get_image_names(
+ current_user: CurrentUserOrDefault,
+ image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
+ categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
+ is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
+ board_id: Optional[str] = Query(
+ default=None,
+ description="The board id to filter by. Use 'none' to find images without a board.",
+ ),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+ starred_first: bool = Query(default=True, description="Whether to sort by starred images first"),
+ search_term: Optional[str] = Query(default=None, description="The term to search for"),
+) -> ImageNamesResult:
+ """Gets ordered list of image names with metadata for optimistic updates"""
+
+ # Validate that the caller can read from this board before listing its images.
+ if board_id is not None and board_id != "none":
+ _assert_board_read_access(board_id, current_user)
+
+ try:
+ result = ApiDependencies.invoker.services.images.get_image_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ return result
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get image names")
+
+
+@images_router.post(
+ "/images_by_names",
+ operation_id="get_images_by_names",
+ responses={200: {"model": list[ImageDTO]}},
+)
+async def get_images_by_names(
+ current_user: CurrentUserOrDefault,
+ image_names: list[str] = Body(embed=True, description="Object containing list of image names to fetch DTOs for"),
+) -> list[ImageDTO]:
+ """Gets image DTOs for the specified image names. Maintains order of input names."""
+
+ try:
+ image_service = ApiDependencies.invoker.services.images
+
+ # Fetch DTOs preserving the order of requested names
+ image_dtos: list[ImageDTO] = []
+ for name in image_names:
+ try:
+ _assert_image_read_access(name, current_user)
+ dto = image_service.get_dto(name)
+ image_dtos.append(dto)
+ except HTTPException:
+ # Skip images the user is not authorized to view
+ continue
+ except Exception:
+ # Skip missing images - they may have been deleted between name fetch and DTO fetch
+ continue
+
+ return image_dtos
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get image DTOs")
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index 99f00423c6c..53c4c68981f 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -1,13 +1,16 @@
# Copyright (c) 2023 Lincoln D. Stein
"""FastAPI route for model configuration records."""
+import contextlib
import io
import pathlib
-import shutil
import traceback
from copy import deepcopy
-from typing import Any, Dict, List, Optional, Type
+from enum import Enum
+from tempfile import TemporaryDirectory
+from typing import List, Optional, Type
+import huggingface_hub
from fastapi import Body, Path, Query, Response, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.routing import APIRouter
@@ -16,28 +19,40 @@
from starlette.exceptions import HTTPException
from typing_extensions import Annotated
+from invokeai.app.api.auth_dependencies import AdminUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.model_images.model_images_common import ModelImageFileNotFoundException
from invokeai.app.services.model_install.model_install_common import ModelInstallJob
from invokeai.app.services.model_records import (
- DuplicateModelException,
InvalidModelException,
ModelRecordChanges,
+ ModelRecordOrderBy,
UnknownModelException,
)
-from invokeai.backend.model_manager.config import (
- AnyModelConfig,
- BaseModelType,
- MainCheckpointConfig,
- ModelFormat,
- ModelType,
- SubModelType,
+from invokeai.app.services.orphaned_models import OrphanedModelInfo
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+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,
+ Main_Checkpoint_SD2_Config,
+ Main_Checkpoint_SDXL_Config,
+ Main_Checkpoint_SDXLRefiner_Config,
)
+from invokeai.backend.model_manager.load.model_cache.cache_stats import CacheStats
from invokeai.backend.model_manager.metadata.fetch.huggingface import HuggingFaceMetadataFetch
from invokeai.backend.model_manager.metadata.metadata_base import ModelMetadataWithFiles, UnknownMetadataException
+from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
from invokeai.backend.model_manager.search import ModelSearch
-from invokeai.backend.model_manager.starter_models import STARTER_MODELS, StarterModel, StarterModelWithoutDependencies
-
-from ..dependencies import ApiDependencies
+from invokeai.backend.model_manager.starter_models import (
+ STARTER_BUNDLES,
+ STARTER_MODELS,
+ StarterModel,
+ StarterModelBundle,
+ StarterModelWithoutDependencies,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
model_manager_router = APIRouter(prefix="/v2/models", tags=["model_manager"])
@@ -53,11 +68,46 @@ class ModelsList(BaseModel):
model_config = ConfigDict(use_enum_values=True)
+class CacheType(str, Enum):
+ """Cache type - one of vram or ram."""
+
+ RAM = "RAM"
+ VRAM = "VRAM"
+
+
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)
##############################################################################
@@ -73,6 +123,7 @@ def add_cover_image_to_model_config(config: AnyModelConfig, dependencies: Type[A
"config_path": "string",
"key": "string",
"hash": "string",
+ "file_size": 1,
"description": "string",
"source": "string",
"converted_at": 0,
@@ -110,6 +161,8 @@ async def list_model_records(
model_format: Optional[ModelFormat] = Query(
default=None, description="Exact match on the format of the model (e.g. 'diffusers')"
),
+ order_by: ModelRecordOrderBy = Query(default=ModelRecordOrderBy.Name, description="The field to order by"),
+ direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
) -> ModelsList:
"""Get a list of models."""
record_store = ApiDependencies.invoker.services.model_manager.store
@@ -118,18 +171,53 @@ async def list_model_records(
for base_model in base_models:
found_models.extend(
record_store.search_by_attr(
- base_model=base_model, model_type=model_type, model_name=model_name, model_format=model_format
+ base_model=base_model,
+ model_type=model_type,
+ model_name=model_name,
+ model_format=model_format,
+ order_by=order_by,
+ direction=direction,
)
)
else:
found_models.extend(
- record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format)
+ record_store.search_by_attr(
+ model_type=model_type,
+ model_name=model_name,
+ model_format=model_format,
+ order_by=order_by,
+ direction=direction,
+ )
)
- 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)
+@model_manager_router.get(
+ "/missing",
+ operation_id="list_missing_models",
+ responses={200: {"description": "List of models with missing files"}},
+)
+async def list_missing_models() -> ModelsList:
+ """Get models whose files are missing from disk.
+
+ These are models that have database entries but their corresponding
+ weight files have been deleted externally (not via Model Manager).
+ """
+ record_store = ApiDependencies.invoker.services.model_manager.store
+ models_path = ApiDependencies.invoker.services.configuration.models_path
+
+ 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)
+
+ return ModelsList(models=missing_models)
+
+
@model_manager_router.get(
"/get_by_attrs",
operation_id="get_model_records_by_attrs",
@@ -148,7 +236,24 @@ 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(
+ "/get_by_hash",
+ operation_id="get_model_records_by_hash",
+ response_model=AnyModelConfig,
+)
+async def get_model_records_by_hash(
+ hash: str = Query(description="The hash of the model"),
+) -> AnyModelConfig:
+ """Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled,
+ as the hash remains stable across reinstallations while the key (UUID) changes."""
+ configs = ApiDependencies.invoker.services.model_manager.store.search_by_hash(hash)
+ if not configs:
+ raise HTTPException(status_code=404, detail="No model found with this hash")
+
+ return prepare_model_config_for_response(configs[0], ApiDependencies)
@model_manager_router.get(
@@ -169,21 +274,55 @@ 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))
-# @model_manager_router.get("/summary", operation_id="list_model_summary")
-# async def list_model_summary(
-# page: int = Query(default=0, description="The page to get"),
-# per_page: int = Query(default=10, description="The number of models per page"),
-# order_by: ModelRecordOrderBy = Query(default=ModelRecordOrderBy.Default, description="The attribute to order by"),
-# ) -> PaginatedResults[ModelSummary]:
-# """Gets a page of model summary data."""
-# record_store = ApiDependencies.invoker.services.model_manager.store
-# results: PaginatedResults[ModelSummary] = record_store.list_models(page=page, per_page=per_page, order_by=order_by)
-# return results
+@model_manager_router.post(
+ "/i/{key}/reidentify",
+ operation_id="reidentify_model",
+ responses={
+ 200: {
+ "description": "The model configuration was retrieved successfully",
+ "content": {"application/json": {"example": example_model_config}},
+ },
+ 400: {"description": "Bad request"},
+ 404: {"description": "The model could not be found"},
+ },
+)
+async def reidentify_model(
+ key: Annotated[str, Path(description="Key of the model to reidentify.")],
+ current_admin: AdminUserOrDefault,
+) -> AnyModelConfig:
+ """Attempt to reidentify a model by re-probing its weights file."""
+ try:
+ config = ApiDependencies.invoker.services.model_manager.store.get_model(key)
+ models_path = ApiDependencies.invoker.services.configuration.models_path
+ if pathlib.Path(config.path).is_relative_to(models_path):
+ model_path = pathlib.Path(config.path)
+ else:
+ model_path = models_path / config.path
+ mod = ModelOnDisk(model_path)
+ result = ModelConfigFactory.from_model_on_disk(mod)
+ if result.config is None:
+ raise InvalidModelException("Unable to identify model format")
+
+ # Retain user-editable fields from the original config
+ result.config.path = config.path
+ result.config.key = config.key
+ result.config.name = config.name
+ result.config.description = config.description
+ result.config.cover_image = config.cover_image
+ 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
+
+ new_config = ApiDependencies.invoker.services.model_manager.store.replace_model(config.key, result.config)
+ return new_config
+ except UnknownModelException as e:
+ raise HTTPException(status_code=404, detail=str(e))
class FoundModel(BaseModel):
@@ -233,9 +372,10 @@ async def scan_for_models(
found_model = FoundModel(path=path, is_installed=is_installed)
scan_results.append(found_model)
except Exception as e:
+ error_type = type(e).__name__
raise HTTPException(
status_code=500,
- detail=f"An error occurred while searching the directory: {e}",
+ detail=f"An error occurred while searching the directory: {error_type}",
)
return scan_results
@@ -290,16 +430,29 @@ async def get_hugging_face_models(
)
async def update_model_record(
key: Annotated[str, Path(description="Unique key of model")],
- changes: Annotated[ModelRecordChanges, Body(description="Model config", example=example_model_input)],
+ changes: Annotated[ModelRecordChanges, Body(description="Model config", examples=[example_model_input])],
+ current_admin: AdminUserOrDefault,
) -> AnyModelConfig:
"""Update a model's config."""
logger = ApiDependencies.invoker.services.logger
record_store = ApiDependencies.invoker.services.model_manager.store
- installer = ApiDependencies.invoker.services.model_manager.install
try:
- record_store.update_model(key, changes=changes)
- config = installer.sync_model_path(key)
- config = add_cover_image_to_model_config(config, ApiDependencies)
+ previous_config = record_store.get_model(key)
+ config = record_store.update_model(key, changes=changes, allow_class_change=True)
+ # Settings that change how the model loads (e.g. fp8_storage, cpu_only) are baked into the cached
+ # nn.Module at load time, so toggling them on a cached model is otherwise silently a no-op until
+ # the entry is evicted. Drop any unlocked cached entries for this model so the next load rebuilds.
+ if _load_settings_changed(previous_config, config):
+ # Drop the model from every per-device cache so the next load on any GPU rebuilds it.
+ dropped = sum(
+ cache.drop_model(key)
+ for cache in ApiDependencies.invoker.services.model_manager.load.ram_caches.values()
+ )
+ if dropped:
+ logger.info(
+ f"Dropped {dropped} cached entr{'y' if dropped == 1 else 'ies'} for model {key} after settings change."
+ )
+ 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))
@@ -309,6 +462,26 @@ async def update_model_record(
return config
+_LOAD_AFFECTING_SETTINGS: tuple[str, ...] = ("fp8_storage", "cpu_only")
+
+
+def _load_settings_changed(previous: AnyModelConfig, updated: AnyModelConfig) -> bool:
+ """Return True if any setting that influences how the model is loaded changed.
+
+ Such settings are read by the loader during `_load_model` and baked into the resulting
+ nn.Module, so a cached entry built under the old value must be evicted for the change
+ to take effect.
+ """
+ if getattr(previous, "cpu_only", None) != getattr(updated, "cpu_only", None):
+ return True
+ previous_settings = getattr(previous, "default_settings", None)
+ updated_settings = getattr(updated, "default_settings", None)
+ for field in _LOAD_AFFECTING_SETTINGS:
+ if getattr(previous_settings, field, None) != getattr(updated_settings, field, None):
+ return True
+ return False
+
+
@model_manager_router.get(
"/i/{key}/image",
operation_id="get_model_image",
@@ -355,6 +528,7 @@ async def get_model_image(
async def update_model_image(
key: Annotated[str, Path(description="Unique key of model")],
image: UploadFile,
+ current_admin: AdminUserOrDefault,
) -> None:
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
@@ -388,6 +562,7 @@ async def update_model_image(
status_code=204,
)
async def delete_model(
+ current_admin: AdminUserOrDefault,
key: str = Path(description="Unique key of model to remove from model registry."),
) -> Response:
"""
@@ -408,6 +583,134 @@ async def delete_model(
raise HTTPException(status_code=404, detail=str(e))
+class BulkDeleteModelsRequest(BaseModel):
+ """Request body for bulk model deletion."""
+
+ keys: List[str] = Field(description="List of model keys to delete")
+
+
+class BulkDeleteModelsResponse(BaseModel):
+ """Response body for bulk model deletion."""
+
+ deleted: List[str] = Field(description="List of successfully deleted model keys")
+ failed: List[dict] = Field(description="List of failed deletions with error messages")
+
+
+class BulkReidentifyModelsRequest(BaseModel):
+ """Request body for bulk model reidentification."""
+
+ keys: List[str] = Field(description="List of model keys to reidentify")
+
+
+class BulkReidentifyModelsResponse(BaseModel):
+ """Response body for bulk model reidentification."""
+
+ succeeded: List[str] = Field(description="List of successfully reidentified model keys")
+ failed: List[dict] = Field(description="List of failed reidentifications with error messages")
+
+
+@model_manager_router.post(
+ "/i/bulk_delete",
+ operation_id="bulk_delete_models",
+ responses={
+ 200: {"description": "Models deleted (possibly with some failures)"},
+ },
+ status_code=200,
+)
+async def bulk_delete_models(
+ current_admin: AdminUserOrDefault,
+ request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"),
+) -> BulkDeleteModelsResponse:
+ """
+ Delete multiple model records from database.
+
+ The configuration records will be removed. The corresponding weights files will be
+ deleted as well if they reside within the InvokeAI "models" directory.
+ Returns a list of successfully deleted keys and failed deletions with error messages.
+ """
+ logger = ApiDependencies.invoker.services.logger
+ installer = ApiDependencies.invoker.services.model_manager.install
+
+ deleted = []
+ failed = []
+
+ for key in request.keys:
+ try:
+ installer.delete(key)
+ deleted.append(key)
+ logger.info(f"Deleted model: {key}")
+ except UnknownModelException as e:
+ logger.error(f"Failed to delete model {key}: {str(e)}")
+ failed.append({"key": key, "error": str(e)})
+ except Exception as e:
+ logger.error(f"Failed to delete model {key}: {str(e)}")
+ failed.append({"key": key, "error": str(e)})
+
+ logger.info(f"Bulk delete completed: {len(deleted)} deleted, {len(failed)} failed")
+ return BulkDeleteModelsResponse(deleted=deleted, failed=failed)
+
+
+@model_manager_router.post(
+ "/i/bulk_reidentify",
+ operation_id="bulk_reidentify_models",
+ responses={
+ 200: {"description": "Models reidentified (possibly with some failures)"},
+ },
+ status_code=200,
+)
+async def bulk_reidentify_models(
+ current_admin: AdminUserOrDefault,
+ request: BulkReidentifyModelsRequest = Body(description="List of model keys to reidentify"),
+) -> BulkReidentifyModelsResponse:
+ """
+ Reidentify multiple models by re-probing their weights files.
+
+ Returns a list of successfully reidentified keys and failed reidentifications with error messages.
+ """
+ logger = ApiDependencies.invoker.services.logger
+ store = ApiDependencies.invoker.services.model_manager.store
+ models_path = ApiDependencies.invoker.services.configuration.models_path
+
+ succeeded = []
+ failed = []
+
+ for key in request.keys:
+ try:
+ config = store.get_model(key)
+ if pathlib.Path(config.path).is_relative_to(models_path):
+ model_path = pathlib.Path(config.path)
+ else:
+ model_path = models_path / config.path
+ mod = ModelOnDisk(model_path)
+ result = ModelConfigFactory.from_model_on_disk(mod)
+ if result.config is None:
+ raise InvalidModelException("Unable to identify model format")
+
+ # Retain user-editable fields from the original config
+ result.config.path = config.path
+ result.config.key = config.key
+ 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"):
+ result.config.trigger_phrases = config.trigger_phrases
+ result.config.source = config.source
+ result.config.source_type = config.source_type
+
+ store.replace_model(config.key, result.config)
+ succeeded.append(key)
+ logger.info(f"Reidentified model: {key}")
+ except UnknownModelException as e:
+ logger.error(f"Failed to reidentify model {key}: {str(e)}")
+ failed.append({"key": key, "error": str(e)})
+ except Exception as e:
+ logger.error(f"Failed to reidentify model {key}: {str(e)}")
+ failed.append({"key": key, "error": str(e)})
+
+ logger.info(f"Bulk reidentify completed: {len(succeeded)} succeeded, {len(failed)} failed")
+ return BulkReidentifyModelsResponse(succeeded=succeeded, failed=failed)
+
+
@model_manager_router.delete(
"/i/{key}/image",
operation_id="delete_model_image",
@@ -418,6 +721,7 @@ async def delete_model(
status_code=204,
)
async def delete_model_image(
+ current_admin: AdminUserOrDefault,
key: str = Path(description="Unique key of model image to remove from model_images directory."),
) -> None:
logger = ApiDependencies.invoker.services.logger
@@ -443,15 +747,14 @@ async def delete_model_image(
status_code=201,
)
async def install_model(
+ current_admin: AdminUserOrDefault,
source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"),
inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False),
- # TODO(MM2): Can we type this?
- config: Optional[Dict[str, Any]] = Body(
- description="Dict of fields that override auto-probed values in the model config record, such as name, description and prediction_type ",
- default=None,
- example={"name": "string", "description": "string"},
+ access_token: Optional[str] = Query(description="access token for the remote resource", default=None),
+ config: ModelRecordChanges = Body(
+ description="Object containing fields that override auto-probed values in the model config record, such as name, description and prediction_type ",
+ examples=[{"name": "string", "description": "string"}],
),
- access_token: Optional[str] = None,
) -> ModelInstallJob:
"""Install a model using a string identifier.
@@ -466,8 +769,9 @@ async def install_model(
- model/name:fp16:path/to/model.safetensors
- model/name::path/to/model.safetensors
- `config` is an optional dict containing model configuration values that will override
- the ones that are probed automatically.
+ `config` is a ModelRecordChanges object. Fields in this object will override
+ the ones that are probed automatically. Pass an empty object to accept
+ all the defaults.
`access_token` is an optional access token for use with Urls that require
authentication.
@@ -514,6 +818,7 @@ async def install_model(
response_class=HTMLResponse,
)
async def install_hugging_face_model(
+ current_admin: AdminUserOrDefault,
source: str = Query(description="HuggingFace repo_id to install"),
) -> HTMLResponse:
"""Install a Hugging Face model using a string identifier."""
@@ -633,7 +938,7 @@ def generate_html(title: str, heading: str, repo_id: str, is_error: bool, messag
"/install",
operation_id="list_model_installs",
)
-async def list_model_installs() -> List[ModelInstallJob]:
+async def list_model_installs(current_admin: AdminUserOrDefault) -> List[ModelInstallJob]:
"""Return the list of model install jobs.
Install jobs have a numeric `id`, a `status`, and other fields that provide information on
@@ -642,6 +947,7 @@ async def list_model_installs() -> List[ModelInstallJob]:
* "waiting" -- Job is waiting in the queue to run
* "downloading" -- Model file(s) are downloading
* "running" -- Model has downloaded and the model probing and registration process is running
+ * "paused" -- Job is paused and can be resumed
* "completed" -- Installation completed successfully
* "error" -- An error occurred. Details will be in the "error_type" and "error" fields.
* "cancelled" -- Job was cancelled before completion.
@@ -664,7 +970,9 @@ async def list_model_installs() -> List[ModelInstallJob]:
404: {"description": "No such job"},
},
)
-async def get_model_install_job(id: int = Path(description="Model install id")) -> ModelInstallJob:
+async def get_model_install_job(
+ current_admin: AdminUserOrDefault, id: int = Path(description="Model install id")
+) -> ModelInstallJob:
"""
Return model install job corresponding to the given source. See the documentation for 'List Model Install Jobs'
for information on the format of the return value.
@@ -685,7 +993,10 @@ async def get_model_install_job(id: int = Path(description="Model install id"))
},
status_code=201,
)
-async def cancel_model_install_job(id: int = Path(description="Model install job ID")) -> None:
+async def cancel_model_install_job(
+ current_admin: AdminUserOrDefault,
+ id: int = Path(description="Model install job ID"),
+) -> None:
"""Cancel the model install job(s) corresponding to the given job ID."""
installer = ApiDependencies.invoker.services.model_manager.install
try:
@@ -695,6 +1006,96 @@ async def cancel_model_install_job(id: int = Path(description="Model install job
installer.cancel_job(job)
+@model_manager_router.post(
+ "/install/{id}/pause",
+ operation_id="pause_model_install_job",
+ responses={
+ 201: {"description": "The job was paused successfully"},
+ 415: {"description": "No such job"},
+ },
+ status_code=201,
+)
+async def pause_model_install_job(
+ current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID")
+) -> ModelInstallJob:
+ """Pause the model install job corresponding to the given job ID."""
+ installer = ApiDependencies.invoker.services.model_manager.install
+ try:
+ job = installer.get_job_by_id(id)
+ except ValueError as e:
+ raise HTTPException(status_code=415, detail=str(e))
+ installer.pause_job(job)
+ return job
+
+
+@model_manager_router.post(
+ "/install/{id}/resume",
+ operation_id="resume_model_install_job",
+ responses={
+ 201: {"description": "The job was resumed successfully"},
+ 415: {"description": "No such job"},
+ },
+ status_code=201,
+)
+async def resume_model_install_job(
+ current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID")
+) -> ModelInstallJob:
+ """Resume a paused model install job corresponding to the given job ID."""
+ installer = ApiDependencies.invoker.services.model_manager.install
+ try:
+ job = installer.get_job_by_id(id)
+ except ValueError as e:
+ raise HTTPException(status_code=415, detail=str(e))
+ installer.resume_job(job)
+ return job
+
+
+@model_manager_router.post(
+ "/install/{id}/restart_failed",
+ operation_id="restart_failed_model_install_job",
+ responses={
+ 201: {"description": "Failed files restarted successfully"},
+ 415: {"description": "No such job"},
+ },
+ status_code=201,
+)
+async def restart_failed_model_install_job(
+ current_admin: AdminUserOrDefault, id: int = Path(description="Model install job ID")
+) -> ModelInstallJob:
+ """Restart failed or non-resumable file downloads for the given job."""
+ installer = ApiDependencies.invoker.services.model_manager.install
+ try:
+ job = installer.get_job_by_id(id)
+ except ValueError as e:
+ raise HTTPException(status_code=415, detail=str(e))
+ installer.restart_failed(job)
+ return job
+
+
+@model_manager_router.post(
+ "/install/{id}/restart_file",
+ operation_id="restart_model_install_file",
+ responses={
+ 201: {"description": "File restarted successfully"},
+ 415: {"description": "No such job"},
+ },
+ status_code=201,
+)
+async def restart_model_install_file(
+ current_admin: AdminUserOrDefault,
+ id: int = Path(description="Model install job ID"),
+ file_source: AnyHttpUrl = Body(description="File download URL to restart"),
+) -> ModelInstallJob:
+ """Restart a specific file download for the given job."""
+ installer = ApiDependencies.invoker.services.model_manager.install
+ try:
+ job = installer.get_job_by_id(id)
+ except ValueError as e:
+ raise HTTPException(status_code=415, detail=str(e))
+ installer.restart_file(job, str(file_source))
+ return job
+
+
@model_manager_router.delete(
"/install",
operation_id="prune_model_install_jobs",
@@ -703,7 +1104,7 @@ async def cancel_model_install_job(id: int = Path(description="Model install job
400: {"description": "Bad request"},
},
)
-async def prune_model_install_jobs() -> Response:
+async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response:
"""Prune all completed and errored jobs from the install job list."""
ApiDependencies.invoker.services.model_manager.install.prune_jobs()
return Response(status_code=204)
@@ -723,6 +1124,7 @@ async def prune_model_install_jobs() -> Response:
},
)
async def convert_model(
+ current_admin: AdminUserOrDefault,
key: str = Path(description="Unique key of the safetensors main model to convert to diffusers format."),
) -> AnyModelConfig:
"""
@@ -742,43 +1144,49 @@ async def convert_model(
logger.error(str(e))
raise HTTPException(status_code=424, detail=str(e))
- if not isinstance(model_config, MainCheckpointConfig):
- logger.error(f"The model with key {key} is not a main checkpoint model.")
- raise HTTPException(400, f"The model with key {key} is not a main checkpoint model.")
-
- # loading the model will convert it into a cached diffusers file
- try:
- cc_size = loader.convert_cache.max_size
- if cc_size == 0: # temporary set the convert cache to a positive number so that cached model is written
- loader._convert_cache.max_size = 1.0
- loader.load_model(model_config, submodel_type=SubModelType.Scheduler)
- finally:
- loader._convert_cache.max_size = cc_size
-
- # Get the path of the converted model from the loader
- cache_path = loader.convert_cache.cache_path(key)
- assert cache_path.exists()
-
- # temporarily rename the original safetensors file so that there is no naming conflict
- original_name = model_config.name
- model_config.name = f"{original_name}.DELETE"
- changes = ModelRecordChanges(name=model_config.name)
- store.update_model(key, changes=changes)
-
- # install the diffusers
- try:
- new_key = installer.install_path(
- cache_path,
- config={
- "name": original_name,
- "description": model_config.description,
- "hash": model_config.hash,
- "source": model_config.source,
- },
- )
- except DuplicateModelException as e:
- logger.error(str(e))
- raise HTTPException(status_code=409, detail=str(e))
+ if not isinstance(
+ model_config,
+ (
+ Main_Checkpoint_SD1_Config,
+ Main_Checkpoint_SD2_Config,
+ Main_Checkpoint_SDXL_Config,
+ Main_Checkpoint_SDXLRefiner_Config,
+ ),
+ ):
+ msg = f"The model with key {key} is not a main SD 1/2/XL checkpoint model."
+ logger.error(msg)
+ raise HTTPException(400, msg)
+
+ with TemporaryDirectory(dir=ApiDependencies.invoker.services.configuration.models_path) as tmpdir:
+ convert_path = pathlib.Path(tmpdir) / pathlib.Path(model_config.path).stem
+ converted_model = loader.load_model(model_config)
+ # write the converted file to the convert path
+ raw_model = converted_model.model
+ assert hasattr(raw_model, "save_pretrained")
+ raw_model.save_pretrained(convert_path) # type: ignore
+ assert convert_path.exists()
+
+ # temporarily rename the original safetensors file so that there is no naming conflict
+ original_name = model_config.name
+ model_config.name = f"{original_name}.DELETE"
+ changes = ModelRecordChanges(name=model_config.name)
+ store.update_model(key, changes=changes)
+
+ # install the diffusers
+ try:
+ new_key = installer.install_path(
+ convert_path,
+ config=ModelRecordChanges(
+ name=original_name,
+ description=model_config.description,
+ hash=model_config.hash,
+ source=model_config.source,
+ ),
+ )
+ except Exception as e:
+ logger.error(str(e))
+ store.update_model(key, changes=ModelRecordChanges(name=original_name))
+ raise HTTPException(status_code=409, detail=str(e))
# Update the model image if the model had one
try:
@@ -791,28 +1199,254 @@ async def convert_model(
# delete the original safetensors file
installer.delete(key)
- # delete the cached version
- shutil.rmtree(cache_path)
+ # delete the temporary directory
+ # shutil.rmtree(cache_path)
# 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
-@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=list[StarterModel])
-async def get_starter_models() -> list[StarterModel]:
+class StarterModelResponse(BaseModel):
+ starter_models: list[StarterModel]
+ starter_bundles: dict[str, StarterModelBundle]
+
+
+def get_is_installed(
+ starter_model: StarterModel | StarterModelWithoutDependencies, installed_models: list[AnyModelConfig]
+) -> bool:
+ from invokeai.backend.model_manager.taxonomy import ModelType
+
+ for model in installed_models:
+ # Check if source matches exactly
+ if model.source == starter_model.source:
+ return True
+ # Check if name (or previous names), base and type match
+ if (
+ (model.name == starter_model.name or model.name in starter_model.previous_names)
+ and model.base == starter_model.base
+ and model.type == starter_model.type
+ ):
+ return True
+
+ # Special handling for Qwen3Encoder models - check by type and variant
+ # This allows renamed models to still be detected as installed
+ if starter_model.type == ModelType.Qwen3Encoder:
+ from invokeai.backend.model_manager.taxonomy import Qwen3VariantType
+
+ # Determine expected variant from source pattern
+ expected_variant: Qwen3VariantType | None = None
+ if "klein-9B" in starter_model.source or "qwen3_8b" in starter_model.source.lower():
+ expected_variant = Qwen3VariantType.Qwen3_8B
+ elif (
+ "klein-4B" in starter_model.source
+ or "qwen3_4b" in starter_model.source.lower()
+ or "Z-Image" in starter_model.source
+ ):
+ expected_variant = Qwen3VariantType.Qwen3_4B
+
+ if expected_variant is not None:
+ for model in installed_models:
+ if model.type == ModelType.Qwen3Encoder and hasattr(model, "variant"):
+ model_variant = model.variant
+ # Handle both enum and string values
+ if isinstance(model_variant, Qwen3VariantType):
+ if model_variant == expected_variant:
+ return True
+ elif isinstance(model_variant, str):
+ if model_variant == expected_variant.value:
+ return True
+
+ return False
+
+
+@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=StarterModelResponse)
+async def get_starter_models() -> StarterModelResponse:
installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr()
- installed_model_sources = {m.source for m in installed_models}
starter_models = deepcopy(STARTER_MODELS)
+ starter_bundles = deepcopy(STARTER_BUNDLES)
for model in starter_models:
- if model.source in installed_model_sources:
- model.is_installed = True
+ model.is_installed = get_is_installed(model, installed_models)
# Remove already-installed dependencies
missing_deps: list[StarterModelWithoutDependencies] = []
+
for dep in model.dependencies or []:
- if dep.source not in installed_model_sources:
+ if not get_is_installed(dep, installed_models):
missing_deps.append(dep)
model.dependencies = missing_deps
- return starter_models
+ for bundle in starter_bundles.values():
+ for model in bundle.models:
+ model.is_installed = get_is_installed(model, installed_models)
+ # Remove already-installed dependencies
+ missing_deps: list[StarterModelWithoutDependencies] = []
+ for dep in model.dependencies or []:
+ if not get_is_installed(dep, installed_models):
+ missing_deps.append(dep)
+ model.dependencies = missing_deps
+
+ return StarterModelResponse(starter_models=starter_models, starter_bundles=starter_bundles)
+
+
+@model_manager_router.get(
+ "/stats",
+ operation_id="get_stats",
+ response_model=Optional[CacheStats],
+ summary="Get model manager RAM cache performance statistics.",
+)
+async def get_stats() -> Optional[CacheStats]:
+ """Return performance statistics on the model manager's RAM cache. Will return null if no models have been loaded."""
+
+ return ApiDependencies.invoker.services.model_manager.load.ram_cache.stats
+
+
+@model_manager_router.post(
+ "/empty_model_cache",
+ operation_id="empty_model_cache",
+ status_code=200,
+)
+async def empty_model_cache(current_admin: AdminUserOrDefault) -> None:
+ """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped."""
+ # Request 1000GB of room in order to force each per-device cache to drop all models.
+ ApiDependencies.invoker.services.logger.info("Emptying model cache.")
+ for cache in ApiDependencies.invoker.services.model_manager.load.ram_caches.values():
+ cache.make_room(1000 * 2**30)
+
+
+class HFTokenStatus(str, Enum):
+ VALID = "valid"
+ INVALID = "invalid"
+ UNKNOWN = "unknown"
+
+
+class HFTokenHelper:
+ @classmethod
+ def get_status(cls) -> HFTokenStatus:
+ try:
+ token = huggingface_hub.get_token()
+ if not token:
+ return HFTokenStatus.INVALID
+ huggingface_hub.whoami(token=token)
+ return HFTokenStatus.VALID
+ except Exception:
+ return HFTokenStatus.UNKNOWN
+
+ @classmethod
+ def set_token(cls, token: str) -> HFTokenStatus:
+ with SuppressOutput(), contextlib.suppress(Exception):
+ huggingface_hub.login(token=token, add_to_git_credential=False)
+ return cls.get_status()
+
+ @classmethod
+ def reset_token(cls) -> HFTokenStatus:
+ with SuppressOutput(), contextlib.suppress(Exception):
+ huggingface_hub.logout()
+ return cls.get_status()
+
+
+@model_manager_router.get("/hf_login", operation_id="get_hf_login_status", response_model=HFTokenStatus)
+async def get_hf_login_status() -> HFTokenStatus:
+ token_status = HFTokenHelper.get_status()
+
+ if token_status is HFTokenStatus.UNKNOWN:
+ ApiDependencies.invoker.services.logger.warning("Unable to verify HF token")
+
+ return token_status
+
+
+@model_manager_router.post("/hf_login", operation_id="do_hf_login", response_model=HFTokenStatus)
+async def do_hf_login(
+ current_admin: AdminUserOrDefault,
+ token: str = Body(description="Hugging Face token to use for login", embed=True),
+) -> HFTokenStatus:
+ HFTokenHelper.set_token(token)
+ token_status = HFTokenHelper.get_status()
+
+ if token_status is HFTokenStatus.UNKNOWN:
+ ApiDependencies.invoker.services.logger.warning("Unable to verify HF token")
+
+ return token_status
+
+
+@model_manager_router.delete("/hf_login", operation_id="reset_hf_token", response_model=HFTokenStatus)
+async def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus:
+ return HFTokenHelper.reset_token()
+
+
+# Orphaned Models Management Routes
+
+
+class DeleteOrphanedModelsRequest(BaseModel):
+ """Request to delete specific orphaned model directories."""
+
+ paths: list[str] = Field(description="List of relative paths to delete")
+
+
+class DeleteOrphanedModelsResponse(BaseModel):
+ """Response from deleting orphaned models."""
+
+ deleted: list[str] = Field(description="Paths that were successfully deleted")
+ errors: dict[str, str] = Field(description="Paths that had errors, with error messages")
+
+
+@model_manager_router.get(
+ "/sync/orphaned",
+ operation_id="get_orphaned_models",
+ response_model=list[OrphanedModelInfo],
+)
+async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]:
+ """Find orphaned model directories.
+
+ Orphaned models are directories in the models folder that contain model files
+ but are not referenced in the database. This can happen when models are deleted
+ from the database but the files remain on disk.
+
+ Returns:
+ List of orphaned model directory information
+ """
+ from invokeai.app.services.orphaned_models import OrphanedModelsService
+
+ # Access the database through the model records service
+ model_records_service = ApiDependencies.invoker.services.model_manager.store
+
+ service = OrphanedModelsService(
+ config=ApiDependencies.invoker.services.configuration,
+ db=model_records_service._db, # Access the database from model records service
+ )
+ return service.find_orphaned_models()
+
+
+@model_manager_router.delete(
+ "/sync/orphaned",
+ operation_id="delete_orphaned_models",
+ response_model=DeleteOrphanedModelsResponse,
+)
+async def delete_orphaned_models(
+ request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault
+) -> DeleteOrphanedModelsResponse:
+ """Delete specified orphaned model directories.
+
+ Args:
+ request: Request containing list of relative paths to delete
+
+ Returns:
+ Response indicating which paths were deleted and which had errors
+ """
+ from invokeai.app.services.orphaned_models import OrphanedModelsService
+
+ # Access the database through the model records service
+ model_records_service = ApiDependencies.invoker.services.model_manager.store
+
+ service = OrphanedModelsService(
+ config=ApiDependencies.invoker.services.configuration,
+ db=model_records_service._db, # Access the database from model records service
+ )
+
+ results = service.delete_orphaned_models(request.paths)
+
+ # Separate successful deletions from errors
+ deleted = [path for path, status in results.items() if status == "deleted"]
+ errors = {path: status for path, status in results.items() if status != "deleted"}
+
+ return DeleteOrphanedModelsResponse(deleted=deleted, errors=errors)
diff --git a/invokeai/app/api/routers/model_relationships.py b/invokeai/app/api/routers/model_relationships.py
new file mode 100644
index 00000000000..0ec45070955
--- /dev/null
+++ b/invokeai/app/api/routers/model_relationships.py
@@ -0,0 +1,210 @@
+"""FastAPI route for model relationship records."""
+
+from typing import List
+
+from fastapi import APIRouter, Body, HTTPException, Path, status
+from pydantic import BaseModel, Field
+
+from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+
+model_relationships_router = APIRouter(prefix="/v1/model_relationships", tags=["model_relationships"])
+
+# === Schemas ===
+
+
+class ModelRelationshipCreateRequest(BaseModel):
+ model_key_1: str = Field(
+ ...,
+ description="The key of the first model in the relationship",
+ examples=[
+ "aa3b247f-90c9-4416-bfcd-aeaa57a5339e",
+ "ac32b914-10ab-496e-a24a-3068724b9c35",
+ "d944abfd-c7c3-42e2-a4ff-da640b29b8b4",
+ "b1c2d3e4-f5a6-7890-abcd-ef1234567890",
+ "12345678-90ab-cdef-1234-567890abcdef",
+ "fedcba98-7654-3210-fedc-ba9876543210",
+ ],
+ )
+ model_key_2: str = Field(
+ ...,
+ description="The key of the second model in the relationship",
+ examples=[
+ "3bb7c0eb-b6c8-469c-ad8c-4d69c06075e4",
+ "f0c3da4e-d9ff-42b5-a45c-23be75c887c9",
+ "38170dd8-f1e5-431e-866c-2c81f1277fcc",
+ "c57fea2d-7646-424c-b9ad-c0ba60fc68be",
+ "10f7807b-ab54-46a9-ab03-600e88c630a1",
+ "f6c1d267-cf87-4ee0-bee0-37e791eacab7",
+ ],
+ )
+
+
+class ModelRelationshipBatchRequest(BaseModel):
+ model_keys: List[str] = Field(
+ ...,
+ description="List of model keys to fetch related models for",
+ examples=[
+ [
+ "aa3b247f-90c9-4416-bfcd-aeaa57a5339e",
+ "ac32b914-10ab-496e-a24a-3068724b9c35",
+ ],
+ [
+ "b1c2d3e4-f5a6-7890-abcd-ef1234567890",
+ "12345678-90ab-cdef-1234-567890abcdef",
+ "fedcba98-7654-3210-fedc-ba9876543210",
+ ],
+ [
+ "3bb7c0eb-b6c8-469c-ad8c-4d69c06075e4",
+ ],
+ ],
+ )
+
+
+# === Routes ===
+
+
+@model_relationships_router.get(
+ "/i/{model_key}",
+ operation_id="get_related_models",
+ response_model=list[str],
+ responses={
+ 200: {
+ "description": "A list of related model keys was retrieved successfully",
+ "content": {
+ "application/json": {
+ "example": [
+ "15e9eb28-8cfe-47c9-b610-37907a79fc3c",
+ "71272e82-0e5f-46d5-bca9-9a61f4bd8a82",
+ "a5d7cd49-1b98-4534-a475-aeee4ccf5fa2",
+ ]
+ }
+ },
+ },
+ 404: {"description": "The specified model could not be found"},
+ 422: {"description": "Validation error"},
+ },
+)
+async def get_related_models(
+ current_user: CurrentUserOrDefault,
+ model_key: str = Path(..., description="The key of the model to get relationships for"),
+) -> list[str]:
+ """
+ Get a list of model keys related to a given model.
+ """
+ return ApiDependencies.invoker.services.model_relationships.get_related_model_keys(model_key)
+
+
+@model_relationships_router.post(
+ "/",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ 204: {"description": "The relationship was successfully created"},
+ 400: {"description": "Invalid model keys or self-referential relationship"},
+ 409: {"description": "The relationship already exists"},
+ 422: {"description": "Validation error"},
+ 500: {"description": "Internal server error"},
+ },
+ summary="Add Model Relationship",
+ description="Creates a **bidirectional** relationship between two models, allowing each to reference the other as related.",
+)
+async def add_model_relationship(
+ current_user: AdminUserOrDefault,
+ req: ModelRelationshipCreateRequest = Body(..., description="The model keys to relate"),
+) -> None:
+ """
+ Add a relationship between two models.
+
+ Relationships are bidirectional and will be accessible from both models.
+
+ - Raises 400 if keys are invalid or identical.
+ - Raises 409 if the relationship already exists.
+ """
+ if req.model_key_1 == req.model_key_2:
+ raise HTTPException(status_code=400, detail="Cannot relate a model to itself.")
+
+ try:
+ ApiDependencies.invoker.services.model_relationships.add_model_relationship(
+ req.model_key_1,
+ req.model_key_2,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+
+
+@model_relationships_router.delete(
+ "/",
+ status_code=status.HTTP_204_NO_CONTENT,
+ responses={
+ 204: {"description": "The relationship was successfully removed"},
+ 400: {"description": "Invalid model keys or self-referential relationship"},
+ 404: {"description": "The relationship does not exist"},
+ 422: {"description": "Validation error"},
+ 500: {"description": "Internal server error"},
+ },
+ summary="Remove Model Relationship",
+ description="Removes a **bidirectional** relationship between two models. The relationship must already exist.",
+)
+async def remove_model_relationship(
+ current_user: AdminUserOrDefault,
+ req: ModelRelationshipCreateRequest = Body(..., description="The model keys to disconnect"),
+) -> None:
+ """
+ Removes a bidirectional relationship between two model keys.
+
+ - Raises 400 if attempting to unlink a model from itself.
+ - Raises 404 if the relationship was not found.
+ """
+ if req.model_key_1 == req.model_key_2:
+ raise HTTPException(status_code=400, detail="Cannot unlink a model from itself.")
+
+ try:
+ ApiDependencies.invoker.services.model_relationships.remove_model_relationship(
+ req.model_key_1,
+ req.model_key_2,
+ )
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+
+
+@model_relationships_router.post(
+ "/batch",
+ operation_id="get_related_models_batch",
+ response_model=List[str],
+ responses={
+ 200: {
+ "description": "Related model keys retrieved successfully",
+ "content": {
+ "application/json": {
+ "example": [
+ "ca562b14-995e-4a42-90c1-9528f1a5921d",
+ "cc0c2b8a-c62e-41d6-878e-cc74dde5ca8f",
+ "18ca7649-6a9e-47d5-bc17-41ab1e8cec81",
+ "7c12d1b2-0ef9-4bec-ba55-797b2d8f2ee1",
+ "c382eaa3-0e28-4ab0-9446-408667699aeb",
+ "71272e82-0e5f-46d5-bca9-9a61f4bd8a82",
+ "a5d7cd49-1b98-4534-a475-aeee4ccf5fa2",
+ ]
+ }
+ },
+ },
+ 422: {"description": "Validation error"},
+ 500: {"description": "Internal server error"},
+ },
+ summary="Get Related Model Keys (Batch)",
+ description="Retrieves all **unique related model keys** for a list of given models. This is useful for contextual suggestions or filtering.",
+)
+async def get_related_models_batch(
+ current_user: CurrentUserOrDefault,
+ req: ModelRelationshipBatchRequest = Body(..., description="Model keys to check for related connections"),
+) -> list[str]:
+ """
+ Accepts multiple model keys and returns a flat list of all unique related keys.
+
+ Useful when working with multiple selections in the UI or cross-model comparisons.
+ """
+ all_related: set[str] = set()
+ for key in req.model_keys:
+ related = ApiDependencies.invoker.services.model_relationships.get_related_model_keys(key)
+ all_related.update(related)
+ return list(all_related)
diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py
new file mode 100644
index 00000000000..d79045d02a5
--- /dev/null
+++ b/invokeai/app/api/routers/recall_parameters.py
@@ -0,0 +1,618 @@
+"""Router for updating recallable parameters on the frontend."""
+
+import json
+from typing import Any, Literal, Optional
+
+from fastapi import Body, HTTPException, Path, Query
+from fastapi.routing import APIRouter
+from pydantic import BaseModel, ConfigDict, Field
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.backend.image_util.controlnet_processor import process_controlnet_image
+from invokeai.backend.model_manager.taxonomy import ModelType
+
+recall_parameters_router = APIRouter(prefix="/v1/recall", tags=["recall"])
+
+
+class LoRARecallParameter(BaseModel):
+ """LoRA configuration for recall"""
+
+ model_name: str = Field(description="The name of the LoRA model")
+ weight: float = Field(default=0.75, ge=-10, le=10, description="The weight for the LoRA")
+ is_enabled: bool = Field(default=True, description="Whether the LoRA is enabled")
+
+
+class ControlNetRecallParameter(BaseModel):
+ """ControlNet configuration for recall"""
+
+ model_name: str = Field(description="The name of the ControlNet/T2I Adapter/Control LoRA model")
+ image_name: Optional[str] = Field(default=None, description="The filename of the control image in outputs/images")
+ weight: float = Field(default=1.0, ge=-1, le=2, description="The weight for the control adapter")
+ begin_step_percent: Optional[float] = Field(
+ default=None, ge=0, le=1, description="When the control adapter is first applied (% of total steps)"
+ )
+ end_step_percent: Optional[float] = Field(
+ default=None, ge=0, le=1, description="When the control adapter is last applied (% of total steps)"
+ )
+ control_mode: Optional[Literal["balanced", "more_prompt", "more_control"]] = Field(
+ default=None, description="The control mode (ControlNet only)"
+ )
+
+
+class IPAdapterRecallParameter(BaseModel):
+ """IP Adapter configuration for recall"""
+
+ model_name: str = Field(description="The name of the IP Adapter model")
+ image_name: Optional[str] = Field(default=None, description="The filename of the reference image in outputs/images")
+ weight: float = Field(default=1.0, ge=-1, le=2, description="The weight for the IP Adapter")
+ begin_step_percent: Optional[float] = Field(
+ default=None, ge=0, le=1, description="When the IP Adapter is first applied (% of total steps)"
+ )
+ end_step_percent: Optional[float] = Field(
+ default=None, ge=0, le=1, description="When the IP Adapter is last applied (% of total steps)"
+ )
+ method: Optional[Literal["full", "style", "composition"]] = Field(default=None, description="The IP Adapter method")
+ image_influence: Optional[Literal["lowest", "low", "medium", "high", "highest"]] = Field(
+ default=None, description="FLUX Redux image influence (if model is flux_redux)"
+ )
+
+
+class ReferenceImageRecallParameter(BaseModel):
+ """Global reference-image configuration for recall.
+
+ Used for reference images that feed directly into the main model rather
+ than through a separate IP-Adapter / ControlNet model — for example
+ FLUX.2 Klein, FLUX Kontext, and Qwen Image Edit. The receiving frontend
+ picks the correct config type (``flux2_reference_image`` /
+ ``qwen_image_reference_image`` / ``flux_kontext_reference_image``) based
+ on the currently-selected main model.
+ """
+
+ image_name: str = Field(description="The filename of the reference image in outputs/images")
+
+
+class RecallParameter(BaseModel):
+ """Request model for updating recallable parameters."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ # Prompts
+ positive_prompt: Optional[str] = Field(None, description="Positive prompt text")
+ negative_prompt: Optional[str] = Field(None, description="Negative prompt text")
+
+ # Model configuration
+ model: Optional[str] = Field(None, description="Main model name/identifier")
+ refiner_model: Optional[str] = Field(None, description="Refiner model name/identifier")
+ vae_model: Optional[str] = Field(None, description="VAE model name/identifier")
+ scheduler: Optional[str] = Field(None, description="Scheduler name")
+
+ # Generation parameters
+ steps: Optional[int] = Field(None, ge=1, description="Number of generation steps")
+ refiner_steps: Optional[int] = Field(None, ge=0, description="Number of refiner steps")
+ cfg_scale: Optional[float] = Field(None, description="CFG scale for guidance")
+ cfg_rescale_multiplier: Optional[float] = Field(None, description="CFG rescale multiplier")
+ refiner_cfg_scale: Optional[float] = Field(None, description="Refiner CFG scale")
+ guidance: Optional[float] = Field(None, description="Guidance scale")
+
+ # Image parameters
+ width: Optional[int] = Field(None, ge=64, description="Image width in pixels")
+ height: Optional[int] = Field(None, ge=64, description="Image height in pixels")
+ seed: Optional[int] = Field(None, ge=0, description="Random seed")
+
+ # Advanced parameters
+ denoise_strength: Optional[float] = Field(None, ge=0, le=1, description="Denoising strength")
+ refiner_denoise_start: Optional[float] = Field(None, ge=0, le=1, description="Refiner denoising start")
+ clip_skip: Optional[int] = Field(None, ge=0, description="CLIP skip layers")
+ seamless_x: Optional[bool] = Field(None, description="Enable seamless X tiling")
+ seamless_y: Optional[bool] = Field(None, description="Enable seamless Y tiling")
+
+ # Refiner aesthetics
+ refiner_positive_aesthetic_score: Optional[float] = Field(None, description="Refiner positive aesthetic score")
+ refiner_negative_aesthetic_score: Optional[float] = Field(None, description="Refiner negative aesthetic score")
+
+ # LoRAs, ControlNets, and IP Adapters
+ loras: Optional[list[LoRARecallParameter]] = Field(None, description="List of LoRAs with their weights")
+ control_layers: Optional[list[ControlNetRecallParameter]] = Field(
+ None, description="List of control adapters (ControlNet, T2I Adapter, Control LoRA) with their settings"
+ )
+ ip_adapters: Optional[list[IPAdapterRecallParameter]] = Field(
+ None, description="List of IP Adapters with their settings"
+ )
+ reference_images: Optional[list[ReferenceImageRecallParameter]] = Field(
+ None,
+ description=(
+ "List of model-free reference images for architectures that consume reference "
+ "images directly (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit). The frontend "
+ "picks the correct config type based on the currently-selected main model."
+ ),
+ )
+
+
+def resolve_model_name_to_key(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
+ """
+ Look up a model by name and return its key.
+
+ Args:
+ model_name: The name of the model to look up
+ model_type: The type of model to search for (default: Main)
+
+ Returns:
+ The key of the first matching model, or None if not found.
+ """
+ logger = ApiDependencies.invoker.services.logger
+ try:
+ models = ApiDependencies.invoker.services.model_manager.store.search_by_attr(
+ model_name=model_name, model_type=model_type
+ )
+
+ if models:
+ logger.info(f"Resolved {model_type.value} model name '{model_name}' to key '{models[0].key}'")
+ return models[0].key
+
+ logger.warning(f"Could not find {model_type.value} model with name '{model_name}'")
+ return None
+ except Exception as e:
+ logger.error(f"Exception during {model_type.value} model lookup: {e}", exc_info=True)
+ return None
+
+
+def load_image_file(image_name: str) -> Optional[dict[str, Any]]:
+ """
+ Load an image from the outputs/images directory.
+
+ Args:
+ image_name: The filename of the image in outputs/images
+
+ Returns:
+ A dictionary with image_name, width, and height, or None if the image cannot be found
+ """
+ logger = ApiDependencies.invoker.services.logger
+ try:
+ images_service = ApiDependencies.invoker.services.images
+ # Use images service which handles subfolder resolution via DB record
+ path = images_service.get_path(image_name)
+
+ if not images_service.validate_path(path):
+ logger.warning(f"Image file not found: {image_name}")
+ return None
+
+ pil_image = images_service.get_pil_image(image_name)
+ width, height = pil_image.size
+ logger.info(f"Found image file: {image_name} ({width}x{height})")
+ return {"image_name": image_name, "width": width, "height": height}
+ except Exception as e:
+ logger.warning(f"Error loading image file {image_name}: {e}")
+ return None
+
+
+def resolve_lora_models(loras: list[LoRARecallParameter]) -> list[dict[str, Any]]:
+ """
+ Resolve LoRA model names to keys and build configuration list.
+
+ Args:
+ loras: List of LoRA recall parameters
+
+ Returns:
+ List of resolved LoRA configurations with model keys
+ """
+ logger = ApiDependencies.invoker.services.logger
+ resolved_loras = []
+
+ for lora in loras:
+ model_key = resolve_model_name_to_key(lora.model_name, ModelType.LoRA)
+ if model_key:
+ resolved_loras.append({"model_key": model_key, "weight": lora.weight, "is_enabled": lora.is_enabled})
+ else:
+ logger.warning(f"Skipping LoRA '{lora.model_name}' - model not found")
+
+ return resolved_loras
+
+
+def resolve_control_models(control_layers: list[ControlNetRecallParameter]) -> list[dict[str, Any]]:
+ """
+ Resolve control adapter model names to keys and build configuration list.
+
+ Tries to resolve as ControlNet, T2I Adapter, or Control LoRA in that order.
+
+ Args:
+ control_layers: List of control adapter recall parameters
+
+ Returns:
+ List of resolved control adapter configurations with model keys
+ """
+ logger = ApiDependencies.invoker.services.logger
+ services = ApiDependencies.invoker.services
+ resolved_controls = []
+
+ for control in control_layers:
+ model_key = None
+
+ # Try ControlNet first
+ model_key = resolve_model_name_to_key(control.model_name, ModelType.ControlNet)
+ if not model_key:
+ # Try T2I Adapter
+ model_key = resolve_model_name_to_key(control.model_name, ModelType.T2IAdapter)
+ if not model_key:
+ # Try Control LoRA (also uses LoRA type)
+ model_key = resolve_model_name_to_key(control.model_name, ModelType.LoRA)
+
+ if model_key:
+ config: dict[str, Any] = {"model_key": model_key, "weight": control.weight}
+ if control.image_name is not None:
+ image_data = load_image_file(control.image_name)
+ if image_data:
+ config["image"] = image_data
+
+ # Try to process the image using the model's default processor
+ processed_image_data = process_controlnet_image(control.image_name, model_key, services)
+ if processed_image_data:
+ config["processed_image"] = processed_image_data
+ logger.info(f"Added processed image for control adapter {control.model_name}")
+ else:
+ logger.warning(f"Could not load image for control adapter: {control.image_name}")
+ if control.begin_step_percent is not None:
+ config["begin_step_percent"] = control.begin_step_percent
+ if control.end_step_percent is not None:
+ config["end_step_percent"] = control.end_step_percent
+ if control.control_mode is not None:
+ config["control_mode"] = control.control_mode
+
+ resolved_controls.append(config)
+ else:
+ logger.warning(f"Skipping control adapter '{control.model_name}' - model not found")
+
+ return resolved_controls
+
+
+def resolve_ip_adapter_models(ip_adapters: list[IPAdapterRecallParameter]) -> list[dict[str, Any]]:
+ """
+ Resolve IP Adapter model names to keys and build configuration list.
+
+ Args:
+ ip_adapters: List of IP Adapter recall parameters
+
+ Returns:
+ List of resolved IP Adapter configurations with model keys
+ """
+ logger = ApiDependencies.invoker.services.logger
+ resolved_adapters = []
+
+ for adapter in ip_adapters:
+ # Try resolving as IP Adapter; if not found, try FLUX Redux
+ model_key = resolve_model_name_to_key(adapter.model_name, ModelType.IPAdapter)
+ if not model_key:
+ model_key = resolve_model_name_to_key(adapter.model_name, ModelType.FluxRedux)
+ if model_key:
+ config: dict[str, Any] = {
+ "model_key": model_key,
+ # Always include weight; ignored by FLUX Redux on the frontend
+ "weight": adapter.weight,
+ }
+ if adapter.image_name is not None:
+ image_data = load_image_file(adapter.image_name)
+ if image_data:
+ config["image"] = image_data
+ else:
+ logger.warning(f"Could not load image for IP Adapter: {adapter.image_name}")
+ if adapter.begin_step_percent is not None:
+ config["begin_step_percent"] = adapter.begin_step_percent
+ if adapter.end_step_percent is not None:
+ config["end_step_percent"] = adapter.end_step_percent
+ if adapter.method is not None:
+ config["method"] = adapter.method
+ # Include FLUX Redux image influence when provided
+ if adapter.image_influence is not None:
+ config["image_influence"] = adapter.image_influence
+
+ resolved_adapters.append(config)
+ else:
+ logger.warning(f"Skipping IP Adapter '{adapter.model_name}' - model not found")
+
+ return resolved_adapters
+
+
+def resolve_reference_images(
+ reference_images: list[ReferenceImageRecallParameter],
+) -> list[dict[str, Any]]:
+ """
+ Validate model-free reference images and build the configuration list.
+
+ Unlike IP Adapters and ControlNets, these reference images are consumed
+ directly by the main model (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit),
+ so there is no adapter-model name to resolve. We simply verify that each
+ referenced file exists in ``outputs/images`` and pass the image metadata
+ through to the frontend.
+
+ Args:
+ reference_images: List of reference-image recall parameters
+
+ Returns:
+ List of reference-image configurations with resolved image metadata.
+ Entries whose image file cannot be loaded are dropped with a warning.
+ """
+ logger = ApiDependencies.invoker.services.logger
+ resolved: list[dict[str, Any]] = []
+
+ for ref in reference_images:
+ image_data = load_image_file(ref.image_name)
+ if image_data is None:
+ logger.warning(f"Skipping reference image '{ref.image_name}' - file not found")
+ continue
+ resolved.append({"image": image_data})
+
+ return resolved
+
+
+def _assert_recall_image_access(parameters: "RecallParameter", current_user: CurrentUserOrDefault) -> None:
+ """Validate that the caller can read every image referenced in the recall parameters.
+
+ Control layers, IP adapters, and reference images may reference image_name fields.
+ Without this check an attacker who knows another user's image UUID could use the recall
+ endpoint to extract image dimensions and — for ControlNet preprocessors — mint
+ a derived processed image they can then fetch.
+ """
+ from invokeai.app.services.board_records.board_records_common import BoardVisibility
+
+ image_names: list[str] = []
+ if parameters.control_layers:
+ for layer in parameters.control_layers:
+ if layer.image_name is not None:
+ image_names.append(layer.image_name)
+ if parameters.ip_adapters:
+ for adapter in parameters.ip_adapters:
+ if adapter.image_name is not None:
+ image_names.append(adapter.image_name)
+ if parameters.reference_images:
+ for ref in parameters.reference_images:
+ if ref.image_name is not None:
+ image_names.append(ref.image_name)
+
+ if not image_names:
+ return
+
+ # Admin can access all images
+ if current_user.is_admin:
+ return
+
+ for image_name in image_names:
+ owner = ApiDependencies.invoker.services.image_records.get_user_id(image_name)
+ if owner is not None and owner == current_user.user_id:
+ continue
+
+ # Check board visibility
+ board_id = ApiDependencies.invoker.services.board_image_records.get_board_for_image(image_name)
+ if board_id is not None:
+ try:
+ board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id)
+ if board.board_visibility in (BoardVisibility.Shared, BoardVisibility.Public):
+ continue
+ except Exception:
+ pass
+
+ raise HTTPException(status_code=403, detail=f"Not authorized to access image {image_name}")
+
+
+@recall_parameters_router.post(
+ "/{queue_id}",
+ operation_id="update_recall_parameters",
+ response_model=dict[str, Any],
+)
+async def update_recall_parameters(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(..., description="The queue id to perform this operation on"),
+ parameters: RecallParameter = Body(..., description="Recall parameters to update"),
+ strict: bool = Query(
+ default=False,
+ description="When true, parameters not included in the request are reset to their defaults (cleared).",
+ ),
+ append: bool = Query(
+ default=False,
+ description=(
+ "When true, recalled reference images (ip_adapters and reference_images) are "
+ "appended to the frontend's existing reference-image list instead of replacing it. "
+ "Mutually exclusive with strict."
+ ),
+ ),
+) -> dict[str, Any]:
+ """
+ Update recallable parameters that can be recalled on the frontend.
+
+ This endpoint allows updating parameters such as prompt, model, steps, and other
+ generation settings. These parameters are stored in client state and can be
+ accessed by the frontend to populate UI elements.
+
+ Args:
+ queue_id: The queue ID to associate these parameters with
+ parameters: The RecallParameter object containing the parameters to update
+ strict: When true, parameters not included in the request body are reset
+ to their defaults (cleared on the frontend). Defaults to false,
+ which preserves the existing behaviour of only updating the
+ parameters that are explicitly provided.
+ append: When true, recalled reference images (``ip_adapters`` and
+ ``reference_images``) are appended to whatever reference images the
+ frontend already has, instead of replacing the whole list. Mutually
+ exclusive with ``strict`` (which clears omitted parameters).
+
+ Returns:
+ A dictionary containing the updated parameters and status
+
+ Example:
+ POST /api/v1/recall/{queue_id}?strict=true
+ {
+ "positive_prompt": "a beautiful landscape",
+ "model": "sd-1.5",
+ "steps": 20
+ }
+ # In strict mode, all other parameters (reference_images, loras, etc.)
+ # are cleared. In non-strict mode (default) they would be left as-is.
+ """
+ logger = ApiDependencies.invoker.services.logger
+
+ if strict and append:
+ raise HTTPException(
+ status_code=400,
+ detail="The 'strict' and 'append' query parameters are mutually exclusive",
+ )
+
+ # Validate image access before processing — prevents information leakage
+ # (dimensions) and derived-image minting via ControlNet preprocessors.
+ _assert_recall_image_access(parameters, current_user)
+
+ try:
+ # In strict mode, include all parameters so the frontend clears anything
+ # not explicitly provided. List-typed fields use [] instead of None so
+ # the frontend sees an empty collection rather than a null it might skip.
+ if strict:
+ _list_fields = {
+ name for name, field in RecallParameter.model_fields.items() if "list" in str(field.annotation).lower()
+ }
+ provided_params = {
+ k: ([] if v is None and k in _list_fields else v) for k, v in parameters.model_dump().items()
+ }
+ else:
+ provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}
+
+ if not provided_params:
+ return {"status": "no_parameters_provided", "updated_count": 0}
+
+ # Store each parameter in client state scoped to the current user
+ updated_count = 0
+ for param_key, param_value in provided_params.items():
+ # Convert parameter values to JSON strings for storage
+ value_str = json.dumps(param_value)
+ try:
+ ApiDependencies.invoker.services.client_state_persistence.set_by_key(
+ current_user.user_id, f"recall_{param_key}", value_str
+ )
+ updated_count += 1
+ except Exception as e:
+ logger.error(f"Error setting recall parameter {param_key}: {e}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Error setting recall parameter {param_key}",
+ )
+
+ logger.info(f"Updated {updated_count} recall parameters for queue {queue_id}")
+
+ # Resolve model name to key if a model was provided
+ if "model" in provided_params and isinstance(provided_params["model"], str):
+ model_name = provided_params["model"]
+ model_key = resolve_model_name_to_key(model_name, ModelType.Main)
+
+ if model_key:
+ logger.info(f"Resolved model name '{model_name}' to key '{model_key}'")
+ provided_params["model"] = model_key
+ else:
+ logger.warning(f"Could not resolve model name '{model_name}' to a model key")
+ # Remove model from parameters if we couldn't resolve it
+ del provided_params["model"]
+
+ # Process LoRAs if provided
+ if "loras" in provided_params:
+ loras_param = parameters.loras
+ if loras_param is not None:
+ resolved_loras = resolve_lora_models(loras_param)
+ provided_params["loras"] = resolved_loras
+ logger.info(f"Resolved {len(resolved_loras)} LoRA(s)")
+
+ # Process control layers if provided
+ if "control_layers" in provided_params:
+ control_layers_param = parameters.control_layers
+ if control_layers_param is not None:
+ resolved_controls = resolve_control_models(control_layers_param)
+ provided_params["control_layers"] = resolved_controls
+ logger.info(f"Resolved {len(resolved_controls)} control layer(s)")
+
+ # Process IP adapters if provided
+ if "ip_adapters" in provided_params:
+ ip_adapters_param = parameters.ip_adapters
+ if ip_adapters_param is not None:
+ resolved_adapters = resolve_ip_adapter_models(ip_adapters_param)
+ provided_params["ip_adapters"] = resolved_adapters
+ logger.info(f"Resolved {len(resolved_adapters)} IP adapter(s)")
+
+ # Process model-free reference images if provided
+ if "reference_images" in provided_params:
+ reference_images_param = parameters.reference_images
+ if reference_images_param is not None:
+ resolved_refs = resolve_reference_images(reference_images_param)
+ provided_params["reference_images"] = resolved_refs
+ logger.info(f"Resolved {len(resolved_refs)} reference image(s)")
+
+ # Append mode rides along inside the event's parameters dict rather
+ # than as a new event field so the generated client schema (which
+ # types parameters as a free-form object) doesn't need regenerating.
+ # Added after the persistence loop above, so the flag itself is never
+ # stored as a recall parameter.
+ if append:
+ provided_params["append"] = True
+
+ # Emit event to notify frontend of parameter updates
+ try:
+ logger.info(
+ f"Emitting recall_parameters_updated event for queue {queue_id} with {len(provided_params)} parameters"
+ )
+ ApiDependencies.invoker.services.events.emit_recall_parameters_updated(
+ queue_id, current_user.user_id, provided_params
+ )
+ logger.info("Successfully emitted recall_parameters_updated event")
+ except Exception as e:
+ logger.error(f"Error emitting recall parameters event: {e}", exc_info=True)
+ # Don't fail the request if event emission fails, just log it
+
+ return {
+ "status": "success",
+ "queue_id": queue_id,
+ "updated_count": updated_count,
+ "parameters": provided_params,
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating recall parameters: {e}")
+ raise HTTPException(
+ status_code=500,
+ detail="Error updating recall parameters",
+ )
+
+
+@recall_parameters_router.get(
+ "/{queue_id}",
+ operation_id="get_recall_parameters",
+ response_model=dict[str, Any],
+)
+async def get_recall_parameters(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(..., description="The queue id to retrieve parameters for"),
+) -> dict[str, Any]:
+ """
+ Retrieve all stored recall parameters for a given queue.
+
+ Returns a dictionary of all recall parameters that have been set for the queue.
+
+ Args:
+ queue_id: The queue ID to retrieve parameters for
+
+ Returns:
+ A dictionary containing all stored recall parameters
+ """
+ logger = ApiDependencies.invoker.services.logger
+
+ try:
+ # Retrieve all recall parameters by iterating through expected keys
+ # Since client_state_persistence doesn't have a "get_all" method, we'll
+ # return an informative response
+ return {
+ "status": "success",
+ "queue_id": queue_id,
+ "note": "Use the frontend to access stored recall parameters, or set specific parameters using POST",
+ }
+
+ except Exception as e:
+ logger.error(f"Error retrieving recall parameters: {e}")
+ raise HTTPException(
+ status_code=500,
+ detail="Error retrieving recall parameters",
+ )
diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py
index 7161e54a41d..cd2260d2271 100644
--- a/invokeai/app/api/routers/session_queue.py
+++ b/invokeai/app/api/routers/session_queue.py
@@ -1,25 +1,32 @@
from typing import Optional
-from fastapi import Body, Path, Query
+from fastapi import Body, HTTPException, Path, Query
from fastapi.routing import APIRouter
from pydantic import BaseModel
+from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import (
- QUEUE_ITEM_STATUS,
Batch,
BatchStatus,
+ CancelAllExceptCurrentResult,
CancelByBatchIDsResult,
+ CancelByDestinationResult,
ClearResult,
+ DeleteAllExceptCurrentResult,
+ DeleteByDestinationResult,
EnqueueBatchResult,
+ ItemIdsResult,
PruneResult,
+ RetryItemsResult,
+ SessionQueueCountsByDestination,
SessionQueueItem,
- SessionQueueItemDTO,
+ SessionQueueItemNotFoundError,
SessionQueueStatus,
)
-from invokeai.app.services.shared.pagination import CursorPaginatedResults
-
-from ..dependencies import ApiDependencies
+from invokeai.app.services.shared.graph import Graph, GraphExecutionState
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"])
@@ -31,6 +38,51 @@ class SessionQueueAndProcessorStatus(BaseModel):
processor: SessionProcessorStatus
+def sanitize_queue_item_for_user(
+ queue_item: SessionQueueItem, current_user_id: str, is_admin: bool
+) -> SessionQueueItem:
+ """Sanitize queue item for non-admin users viewing other users' items.
+
+ For non-admin users viewing queue items belonging to other users,
+ only timestamps, status, and error information are exposed. All other
+ fields (user identity, generation parameters, graphs, workflows) are stripped.
+
+ Args:
+ queue_item: The queue item to sanitize
+ current_user_id: The ID of the current user viewing the item
+ is_admin: Whether the current user is an admin
+
+ Returns:
+ The sanitized queue item (sensitive fields cleared if necessary)
+ """
+ # Admins and item owners can see everything
+ if is_admin or queue_item.user_id == current_user_id:
+ return queue_item
+
+ # For non-admins viewing other users' items, strip everything except
+ # item_id, queue_id, status, and timestamps
+ sanitized_item = queue_item.model_copy(deep=False)
+ sanitized_item.user_id = "redacted"
+ sanitized_item.user_display_name = None
+ sanitized_item.user_email = None
+ sanitized_item.batch_id = "redacted"
+ sanitized_item.session_id = "redacted"
+ sanitized_item.origin = None
+ sanitized_item.destination = None
+ sanitized_item.priority = 0
+ sanitized_item.field_values = None
+ sanitized_item.retried_from_item_id = None
+ sanitized_item.workflow = None
+ sanitized_item.error_type = None
+ sanitized_item.error_message = None
+ sanitized_item.error_traceback = None
+ sanitized_item.session = GraphExecutionState(
+ id="redacted",
+ graph=Graph(),
+ )
+ return sanitized_item
+
+
@session_queue_router.post(
"/{queue_id}/enqueue_batch",
operation_id="enqueue_batch",
@@ -39,34 +91,105 @@ class SessionQueueAndProcessorStatus(BaseModel):
},
)
async def enqueue_batch(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
batch: Batch = Body(description="Batch to process"),
prepend: bool = Body(default=False, description="Whether or not to prepend this batch in the queue"),
) -> EnqueueBatchResult:
- """Processes a batch and enqueues the output graphs for execution."""
+ """Processes a batch and enqueues the output graphs for execution for the current user."""
+ try:
+ return await ApiDependencies.invoker.services.session_queue.enqueue_batch(
+ queue_id=queue_id, batch=batch, prepend=prepend, user_id=current_user.user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while enqueuing batch: {e}")
+
- return ApiDependencies.invoker.services.session_queue.enqueue_batch(queue_id=queue_id, batch=batch, prepend=prepend)
+@session_queue_router.get(
+ "/{queue_id}/list_all",
+ operation_id="list_all_queue_items",
+ responses={
+ 200: {"model": list[SessionQueueItem]},
+ },
+)
+async def list_all_queue_items(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+ destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"),
+) -> list[SessionQueueItem]:
+ """Gets all queue items"""
+ try:
+ items = ApiDependencies.invoker.services.session_queue.list_all_queue_items(
+ queue_id=queue_id,
+ destination=destination,
+ )
+ # Sanitize items for non-admin users
+ return [sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) for item in items]
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue items: {e}")
@session_queue_router.get(
- "/{queue_id}/list",
- operation_id="list_queue_items",
+ "/{queue_id}/item_ids",
+ operation_id="get_queue_item_ids",
responses={
- 200: {"model": CursorPaginatedResults[SessionQueueItemDTO]},
+ 200: {"model": ItemIdsResult},
},
)
-async def list_queue_items(
+async def get_queue_item_ids(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
- limit: int = Query(default=50, description="The number of items to fetch"),
- status: Optional[QUEUE_ITEM_STATUS] = Query(default=None, description="The status of items to fetch"),
- cursor: Optional[int] = Query(default=None, description="The pagination cursor"),
- priority: int = Query(default=0, description="The pagination cursor priority"),
-) -> CursorPaginatedResults[SessionQueueItemDTO]:
- """Gets all queue items (without graphs)"""
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"),
+) -> ItemIdsResult:
+ """Gets all queue item ids that match the given parameters.
- return ApiDependencies.invoker.services.session_queue.list_queue_items(
- queue_id=queue_id, limit=limit, status=status, cursor=cursor, priority=priority
- )
+ IDs for every user's items are returned (item ids carry no sensitive data on their own).
+ When the corresponding items are hydrated via get_queue_items_by_item_ids, those belonging
+ to other users are redacted by sanitize_queue_item_for_user. This lets a non-admin see
+ partially-redacted entries for other users' jobs in the queue list, while still revealing
+ only timestamps and status for items they do not own.
+
+ current_user is required so the endpoint stays behind authentication in multiuser mode.
+ """
+ try:
+ return ApiDependencies.invoker.services.session_queue.get_queue_item_ids(queue_id=queue_id, order_dir=order_dir)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue item ids: {e}")
+
+
+@session_queue_router.post(
+ "/{queue_id}/items_by_ids",
+ operation_id="get_queue_items_by_item_ids",
+ responses={200: {"model": list[SessionQueueItem]}},
+)
+async def get_queue_items_by_item_ids(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+ item_ids: list[int] = Body(
+ embed=True, description="Object containing list of queue item ids to fetch queue items for"
+ ),
+) -> list[SessionQueueItem]:
+ """Gets queue items for the specified queue item ids. Maintains order of item ids."""
+ try:
+ session_queue_service = ApiDependencies.invoker.services.session_queue
+
+ # Fetch queue items preserving the order of requested item ids
+ queue_items: list[SessionQueueItem] = []
+ for item_id in item_ids:
+ try:
+ queue_item = session_queue_service.get_queue_item(item_id=item_id)
+ if queue_item.queue_id != queue_id: # Auth protection for items from other queues
+ continue
+ # Sanitize item for non-admin users
+ sanitized_item = sanitize_queue_item_for_user(queue_item, current_user.user_id, current_user.is_admin)
+ queue_items.append(sanitized_item)
+ except Exception:
+ # Skip missing queue items - they may have been deleted between item id fetch and queue item fetch
+ continue
+
+ return queue_items
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get queue items")
@session_queue_router.put(
@@ -75,10 +198,14 @@ async def list_queue_items(
responses={200: {"model": SessionProcessorStatus}},
)
async def resume(
+ current_user: AdminUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> SessionProcessorStatus:
- """Resumes session processor"""
- return ApiDependencies.invoker.services.session_processor.resume()
+ """Resumes session processor. Admin only."""
+ try:
+ return ApiDependencies.invoker.services.session_processor.resume()
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while resuming queue: {e}")
@session_queue_router.put(
@@ -86,11 +213,55 @@ async def resume(
operation_id="pause",
responses={200: {"model": SessionProcessorStatus}},
)
-async def Pause(
+async def pause(
+ current_user: AdminUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> SessionProcessorStatus:
- """Pauses session processor"""
- return ApiDependencies.invoker.services.session_processor.pause()
+ """Pauses session processor. Admin only."""
+ try:
+ return ApiDependencies.invoker.services.session_processor.pause()
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while pausing queue: {e}")
+
+
+@session_queue_router.put(
+ "/{queue_id}/cancel_all_except_current",
+ operation_id="cancel_all_except_current",
+ responses={200: {"model": CancelAllExceptCurrentResult}},
+)
+async def cancel_all_except_current(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+) -> CancelAllExceptCurrentResult:
+ """Immediately cancels all queue items except in-processing items. Non-admin users can only cancel their own items."""
+ try:
+ # Admin users can cancel all items, non-admin users can only cancel their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(
+ queue_id=queue_id, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while canceling all except current: {e}")
+
+
+@session_queue_router.put(
+ "/{queue_id}/delete_all_except_current",
+ operation_id="delete_all_except_current",
+ responses={200: {"model": DeleteAllExceptCurrentResult}},
+)
+async def delete_all_except_current(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+) -> DeleteAllExceptCurrentResult:
+ """Immediately deletes all queue items except in-processing items. Non-admin users can only delete their own items."""
+ try:
+ # Admin users can delete all items, non-admin users can only delete their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.delete_all_except_current(
+ queue_id=queue_id, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while deleting all except current: {e}")
@session_queue_router.put(
@@ -99,11 +270,72 @@ async def Pause(
responses={200: {"model": CancelByBatchIDsResult}},
)
async def cancel_by_batch_ids(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True),
) -> CancelByBatchIDsResult:
- """Immediately cancels all queue items from the given batch ids"""
- return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids(queue_id=queue_id, batch_ids=batch_ids)
+ """Immediately cancels all queue items from the given batch ids. Non-admin users can only cancel their own items."""
+ try:
+ # Admin users can cancel all items, non-admin users can only cancel their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids(
+ queue_id=queue_id, batch_ids=batch_ids, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by batch id: {e}")
+
+
+@session_queue_router.put(
+ "/{queue_id}/cancel_by_destination",
+ operation_id="cancel_by_destination",
+ responses={200: {"model": CancelByDestinationResult}},
+)
+async def cancel_by_destination(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+ destination: str = Query(description="The destination to cancel all queue items for"),
+) -> CancelByDestinationResult:
+ """Immediately cancels all queue items with the given destination. Non-admin users can only cancel their own items."""
+ try:
+ # Admin users can cancel all items, non-admin users can only cancel their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.cancel_by_destination(
+ queue_id=queue_id, destination=destination, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by destination: {e}")
+
+
+@session_queue_router.put(
+ "/{queue_id}/retry_items_by_id",
+ operation_id="retry_items_by_id",
+ responses={200: {"model": RetryItemsResult}},
+)
+async def retry_items_by_id(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+ item_ids: list[int] = Body(description="The queue item ids to retry"),
+) -> RetryItemsResult:
+ """Retries the given queue items. Users can only retry their own items unless they are an admin."""
+ try:
+ # Check authorization: user must own all items or be an admin
+ if not current_user.is_admin:
+ for item_id in item_ids:
+ try:
+ queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
+ if queue_item.user_id != current_user.user_id:
+ raise HTTPException(
+ status_code=403, detail=f"You do not have permission to retry queue item {item_id}"
+ )
+ except SessionQueueItemNotFoundError:
+ # Skip items that don't exist - they will be handled by retry_items_by_id
+ continue
+
+ return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids)
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while retrying queue items: {e}")
@session_queue_router.put(
@@ -114,14 +346,27 @@ async def cancel_by_batch_ids(
},
)
async def clear(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> ClearResult:
- """Clears the queue entirely, immediately canceling the currently-executing session"""
- queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
- if queue_item is not None:
- ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id)
- clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id)
- return clear_result
+ """Clears the queue entirely. Admin users clear all items; non-admin users only clear their own items. If there's a currently-executing item, users can only cancel it if they own it or are an admin."""
+ try:
+ queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
+ if queue_item is not None:
+ # Check authorization for canceling the current item
+ if queue_item.user_id != current_user.user_id and not current_user.is_admin:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to cancel the currently executing queue item"
+ )
+ ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id)
+ # Admin users can clear all items, non-admin users can only clear their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id, user_id=user_id)
+ return clear_result
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while clearing queue: {e}")
@session_queue_router.put(
@@ -132,10 +377,16 @@ async def clear(
},
)
async def prune(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> PruneResult:
- """Prunes all completed or errored queue items"""
- return ApiDependencies.invoker.services.session_queue.prune(queue_id)
+ """Prunes all completed or errored queue items. Non-admin users can only prune their own items."""
+ try:
+ # Admin users can prune all items, non-admin users can only prune their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.prune(queue_id, user_id=user_id)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while pruning queue: {e}")
@session_queue_router.get(
@@ -146,10 +397,17 @@ async def prune(
},
)
async def get_current_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> Optional[SessionQueueItem]:
"""Gets the currently execution queue item"""
- return ApiDependencies.invoker.services.session_queue.get_current(queue_id)
+ try:
+ item = ApiDependencies.invoker.services.session_queue.get_current(queue_id)
+ if item is not None:
+ item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin)
+ return item
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while getting current queue item: {e}")
@session_queue_router.get(
@@ -160,10 +418,17 @@ async def get_current_queue_item(
},
)
async def get_next_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> Optional[SessionQueueItem]:
"""Gets the next queue item, without executing it"""
- return ApiDependencies.invoker.services.session_queue.get_next(queue_id)
+ try:
+ item = ApiDependencies.invoker.services.session_queue.get_next(queue_id)
+ if item is not None:
+ item = sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin)
+ return item
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while getting next queue item: {e}")
@session_queue_router.get(
@@ -174,12 +439,17 @@ async def get_next_queue_item(
},
)
async def get_queue_status(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
) -> SessionQueueAndProcessorStatus:
- """Gets the status of the session queue"""
- queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id)
- processor = ApiDependencies.invoker.services.session_processor.get_status()
- return SessionQueueAndProcessorStatus(queue=queue, processor=processor)
+ """Gets the status of the session queue. Non-admin users see only their own counts and cannot see current item details unless they own it."""
+ try:
+ user_id = None if current_user.is_admin else current_user.user_id
+ queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=user_id)
+ processor = ApiDependencies.invoker.services.session_processor.get_status()
+ return SessionQueueAndProcessorStatus(queue=queue, processor=processor)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while getting queue status: {e}")
@session_queue_router.get(
@@ -190,11 +460,18 @@ async def get_queue_status(
},
)
async def get_batch_status(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
batch_id: str = Path(description="The batch to get the status of"),
) -> BatchStatus:
- """Gets the status of the session queue"""
- return ApiDependencies.invoker.services.session_queue.get_batch_status(queue_id=queue_id, batch_id=batch_id)
+ """Gets the status of a batch. Non-admin users only see their own batches."""
+ try:
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.get_batch_status(
+ queue_id=queue_id, batch_id=batch_id, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while getting batch status: {e}")
@session_queue_router.get(
@@ -206,11 +483,48 @@ async def get_batch_status(
response_model_exclude_none=True,
)
async def get_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
item_id: int = Path(description="The queue item to get"),
) -> SessionQueueItem:
"""Gets a queue item"""
- return ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
+ try:
+ queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id=item_id)
+ if queue_item.queue_id != queue_id:
+ raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
+ # Sanitize item for non-admin users
+ return sanitize_queue_item_for_user(queue_item, current_user.user_id, current_user.is_admin)
+ except SessionQueueItemNotFoundError:
+ raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while fetching queue item: {e}")
+
+
+@session_queue_router.delete(
+ "/{queue_id}/i/{item_id}",
+ operation_id="delete_queue_item",
+)
+async def delete_queue_item(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to perform this operation on"),
+ item_id: int = Path(description="The queue item to delete"),
+) -> None:
+ """Deletes a queue item. Users can only delete their own items unless they are an admin."""
+ try:
+ # Get the queue item to check ownership
+ queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
+
+ # Check authorization: user must own the item or be an admin
+ if queue_item.user_id != current_user.user_id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="You do not have permission to delete this queue item")
+
+ ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id)
+ except SessionQueueItemNotFoundError:
+ raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while deleting queue item: {e}")
@session_queue_router.put(
@@ -221,9 +535,64 @@ async def get_queue_item(
},
)
async def cancel_queue_item(
+ current_user: CurrentUserOrDefault,
queue_id: str = Path(description="The queue id to perform this operation on"),
item_id: int = Path(description="The queue item to cancel"),
) -> SessionQueueItem:
- """Deletes a queue item"""
+ """Cancels a queue item. Users can only cancel their own items unless they are an admin."""
+ try:
+ # Get the queue item to check ownership
+ queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id)
+
+ # Check authorization: user must own the item or be an admin
+ if queue_item.user_id != current_user.user_id and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="You do not have permission to cancel this queue item")
- return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id)
+ return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id)
+ except SessionQueueItemNotFoundError:
+ raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}")
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}")
+
+
+@session_queue_router.get(
+ "/{queue_id}/counts_by_destination",
+ operation_id="counts_by_destination",
+ responses={200: {"model": SessionQueueCountsByDestination}},
+)
+async def counts_by_destination(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to query"),
+ destination: str = Query(description="The destination to query"),
+) -> SessionQueueCountsByDestination:
+ """Gets the counts of queue items by destination. Non-admin users only see their own items."""
+ try:
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.get_counts_by_destination(
+ queue_id=queue_id, destination=destination, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while fetching counts by destination: {e}")
+
+
+@session_queue_router.delete(
+ "/{queue_id}/d/{destination}",
+ operation_id="delete_by_destination",
+ responses={200: {"model": DeleteByDestinationResult}},
+)
+async def delete_by_destination(
+ current_user: CurrentUserOrDefault,
+ queue_id: str = Path(description="The queue id to query"),
+ destination: str = Path(description="The destination to query"),
+) -> DeleteByDestinationResult:
+ """Deletes all items with the given destination. Non-admin users can only delete their own items."""
+ try:
+ # Admin users can delete all items, non-admin users can only delete their own
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.session_queue.delete_by_destination(
+ queue_id=queue_id, destination=destination, user_id=user_id
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Unexpected error while deleting by destination: {e}")
diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py
new file mode 100644
index 00000000000..91acf8e7a6b
--- /dev/null
+++ b/invokeai/app/api/routers/style_presets.py
@@ -0,0 +1,339 @@
+import csv
+import io
+import json
+import traceback
+from typing import Optional
+
+import pydantic
+from fastapi import APIRouter, File, Form, HTTPException, Path, Response, UploadFile
+from fastapi.responses import FileResponse
+from PIL import Image
+from pydantic import BaseModel, Field
+
+from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE
+from invokeai.app.services.auth.token_service import TokenData
+from invokeai.app.services.style_preset_images.style_preset_images_common import StylePresetImageFileNotFoundException
+from invokeai.app.services.style_preset_records.style_preset_records_common import (
+ InvalidPresetImportDataError,
+ PresetData,
+ PresetType,
+ StylePresetChanges,
+ StylePresetNotFoundError,
+ StylePresetRecordDTO,
+ StylePresetRecordWithImage,
+ StylePresetWithoutId,
+ UnsupportedFileTypeError,
+ parse_presets_from_file,
+)
+
+
+class StylePresetFormData(BaseModel):
+ name: str = Field(description="Preset name")
+ positive_prompt: str = Field(description="Positive prompt")
+ negative_prompt: str = Field(description="Negative prompt")
+ type: PresetType = Field(description="Preset type")
+ is_public: bool = Field(default=False, description="Whether the preset is visible to other users")
+
+
+style_presets_router = APIRouter(prefix="/v1/style_presets", tags=["style_presets"])
+
+
+def _assert_preset_read(record: StylePresetRecordDTO, current_user: TokenData) -> None:
+ """Allow read access if admin, owner, default preset, or public preset."""
+ if current_user.is_admin:
+ return
+ if record.type == PresetType.Default:
+ return
+ if record.is_public:
+ return
+ if record.user_id == current_user.user_id:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to access this style preset")
+
+
+def _assert_preset_write(record: StylePresetRecordDTO, current_user: TokenData) -> None:
+ """Allow write access only for admin or owner. Defaults are immutable for non-admins."""
+ if current_user.is_admin:
+ return
+ if record.type == PresetType.Default:
+ raise HTTPException(status_code=403, detail="Default style presets cannot be modified")
+ if record.user_id == current_user.user_id:
+ return
+ raise HTTPException(status_code=403, detail="Not authorized to modify this style preset")
+
+
+def _load_record_or_404(style_preset_id: str) -> StylePresetRecordDTO:
+ try:
+ return ApiDependencies.invoker.services.style_preset_records.get(style_preset_id)
+ except StylePresetNotFoundError:
+ raise HTTPException(status_code=404, detail="Style preset not found")
+
+
+@style_presets_router.get(
+ "/i/{style_preset_id}",
+ operation_id="get_style_preset",
+ responses={
+ 200: {"model": StylePresetRecordWithImage},
+ },
+)
+async def get_style_preset(
+ current_user: CurrentUserOrDefault,
+ style_preset_id: str = Path(description="The style preset to get"),
+) -> StylePresetRecordWithImage:
+ """Gets a style preset"""
+ record = _load_record_or_404(style_preset_id)
+ _assert_preset_read(record, current_user)
+ image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id)
+ return StylePresetRecordWithImage(image=image, **record.model_dump())
+
+
+@style_presets_router.patch(
+ "/i/{style_preset_id}",
+ operation_id="update_style_preset",
+ responses={
+ 200: {"model": StylePresetRecordWithImage},
+ },
+)
+async def update_style_preset(
+ current_user: CurrentUserOrDefault,
+ image: Optional[UploadFile] = File(description="The image file to upload", default=None),
+ style_preset_id: str = Path(description="The id of the style preset to update"),
+ data: str = Form(description="The data of the style preset to update"),
+) -> StylePresetRecordWithImage:
+ """Updates a style preset"""
+ # Validate the data payload BEFORE any image-state mutation so a malformed
+ # request can't leave the preset image partially updated.
+ try:
+ parsed_data = json.loads(data)
+ validated_data = StylePresetFormData(**parsed_data)
+
+ name = validated_data.name
+ type = validated_data.type
+ positive_prompt = validated_data.positive_prompt
+ negative_prompt = validated_data.negative_prompt
+ is_public = validated_data.is_public
+
+ except (json.JSONDecodeError, pydantic.ValidationError):
+ raise HTTPException(status_code=400, detail="Invalid preset data")
+
+ record = _load_record_or_404(style_preset_id)
+ _assert_preset_write(record, current_user)
+
+ if image is not None:
+ if not image.content_type or not image.content_type.startswith("image"):
+ raise HTTPException(status_code=415, detail="Not an image")
+
+ contents = await image.read()
+ try:
+ pil_image = Image.open(io.BytesIO(contents))
+
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=415, detail="Failed to read image")
+
+ try:
+ ApiDependencies.invoker.services.style_preset_image_files.save(style_preset_id, pil_image)
+ except ValueError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+ else:
+ try:
+ ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id)
+ except StylePresetImageFileNotFoundException:
+ pass
+
+ preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt)
+ changes = StylePresetChanges(name=name, preset_data=preset_data, type=type, is_public=is_public)
+
+ style_preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id)
+ style_preset = ApiDependencies.invoker.services.style_preset_records.update(
+ style_preset_id=style_preset_id, changes=changes
+ )
+ return StylePresetRecordWithImage(image=style_preset_image, **style_preset.model_dump())
+
+
+@style_presets_router.delete(
+ "/i/{style_preset_id}",
+ operation_id="delete_style_preset",
+)
+async def delete_style_preset(
+ current_user: CurrentUserOrDefault,
+ style_preset_id: str = Path(description="The style preset to delete"),
+) -> None:
+ """Deletes a style preset"""
+ record = _load_record_or_404(style_preset_id)
+ _assert_preset_write(record, current_user)
+
+ try:
+ ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id)
+ except StylePresetImageFileNotFoundException:
+ pass
+
+ ApiDependencies.invoker.services.style_preset_records.delete(style_preset_id)
+
+
+@style_presets_router.post(
+ "/",
+ operation_id="create_style_preset",
+ responses={
+ 200: {"model": StylePresetRecordWithImage},
+ },
+)
+async def create_style_preset(
+ current_user: CurrentUserOrDefault,
+ image: Optional[UploadFile] = File(description="The image file to upload", default=None),
+ data: str = Form(description="The data of the style preset to create"),
+) -> StylePresetRecordWithImage:
+ """Creates a style preset"""
+
+ try:
+ parsed_data = json.loads(data)
+ validated_data = StylePresetFormData(**parsed_data)
+
+ name = validated_data.name
+ type = validated_data.type
+ positive_prompt = validated_data.positive_prompt
+ negative_prompt = validated_data.negative_prompt
+ is_public = validated_data.is_public
+
+ except (json.JSONDecodeError, pydantic.ValidationError):
+ raise HTTPException(status_code=400, detail="Invalid preset data")
+
+ # Only admins may create default-typed presets — they're the shipped catalog.
+ if type == PresetType.Default and not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Only admins can create default presets")
+
+ preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt)
+ style_preset = StylePresetWithoutId(name=name, preset_data=preset_data, type=type, is_public=is_public)
+ new_style_preset = ApiDependencies.invoker.services.style_preset_records.create(
+ style_preset=style_preset, user_id=current_user.user_id
+ )
+
+ if image is not None:
+ if not image.content_type or not image.content_type.startswith("image"):
+ raise HTTPException(status_code=415, detail="Not an image")
+
+ contents = await image.read()
+ try:
+ pil_image = Image.open(io.BytesIO(contents))
+
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=415, detail="Failed to read image")
+
+ try:
+ ApiDependencies.invoker.services.style_preset_image_files.save(new_style_preset.id, pil_image)
+ except ValueError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+
+ preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(new_style_preset.id)
+ return StylePresetRecordWithImage(image=preset_image, **new_style_preset.model_dump())
+
+
+@style_presets_router.get(
+ "/",
+ operation_id="list_style_presets",
+ responses={
+ 200: {"model": list[StylePresetRecordWithImage]},
+ },
+)
+async def list_style_presets(current_user: CurrentUserOrDefault) -> list[StylePresetRecordWithImage]:
+ """Gets the style presets visible to the current user."""
+ style_presets_with_image: list[StylePresetRecordWithImage] = []
+ style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ for preset in style_presets:
+ image = ApiDependencies.invoker.services.style_preset_image_files.get_url(preset.id)
+ style_preset_with_image = StylePresetRecordWithImage(image=image, **preset.model_dump())
+ style_presets_with_image.append(style_preset_with_image)
+
+ return style_presets_with_image
+
+
+@style_presets_router.get(
+ "/i/{style_preset_id}/image",
+ operation_id="get_style_preset_image",
+ responses={
+ 200: {
+ "description": "The style preset image was fetched successfully",
+ },
+ 400: {"description": "Bad request"},
+ 404: {"description": "The style preset image could not be found"},
+ },
+ status_code=200,
+)
+async def get_style_preset_image(
+ current_user: CurrentUserOrDefault,
+ style_preset_id: str = Path(description="The id of the style preset image to get"),
+) -> FileResponse:
+ """Gets an image file that previews the model"""
+ record = _load_record_or_404(style_preset_id)
+ _assert_preset_read(record, current_user)
+
+ try:
+ path = ApiDependencies.invoker.services.style_preset_image_files.get_path(style_preset_id)
+
+ response = FileResponse(
+ path,
+ media_type="image/png",
+ filename=style_preset_id + ".png",
+ content_disposition_type="inline",
+ )
+ response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
+ return response
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@style_presets_router.get(
+ "/export",
+ operation_id="export_style_presets",
+ responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}},
+ status_code=200,
+)
+async def export_style_presets(current_user: AdminUserOrDefault):
+ # Admin-only export covers every user preset.
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ writer.writerow(["name", "prompt", "negative_prompt"])
+
+ style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(
+ type=PresetType.User,
+ user_id=current_user.user_id,
+ is_admin=True,
+ )
+
+ for preset in style_presets:
+ writer.writerow([preset.name, preset.preset_data.positive_prompt, preset.preset_data.negative_prompt])
+
+ csv_data = output.getvalue()
+ output.close()
+
+ return Response(
+ content=csv_data,
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"},
+ )
+
+
+@style_presets_router.post(
+ "/import",
+ operation_id="import_style_presets",
+)
+async def import_style_presets(
+ current_user: AdminUserOrDefault,
+ file: UploadFile = File(description="The file to import"),
+):
+ try:
+ style_presets = await parse_presets_from_file(file)
+ ApiDependencies.invoker.services.style_preset_records.create_many(style_presets, user_id=current_user.user_id)
+ except InvalidPresetImportDataError as e:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=400, detail=str(e))
+ except UnsupportedFileTypeError as e:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=415, detail=str(e))
diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py
index 2a912dfacf2..568546603ab 100644
--- a/invokeai/app/api/routers/utilities.py
+++ b/invokeai/app/api/routers/utilities.py
@@ -1,13 +1,34 @@
+import asyncio
+import logging
+import threading
+from pathlib import Path
from typing import Optional, Union
+import torch
from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator
-from fastapi import Body
+from fastapi import Body, HTTPException
from fastapi.routing import APIRouter
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from pyparsing import ParseException
+from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api.routers._access import assert_image_read_access
+from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
+from invokeai.app.services.model_records.model_records_base import UnknownModelException
+from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline
+from invokeai.backend.model_manager.taxonomy import ModelType
+from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline
+from invokeai.backend.util.devices import TorchDevice
+
+logger = logging.getLogger(__name__)
utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"])
+# The underlying model loader is not thread-safe, so we serialize load_model calls.
+_model_load_lock = threading.Lock()
+
class DynamicPromptsResponse(BaseModel):
prompts: list[str]
@@ -22,9 +43,11 @@ class DynamicPromptsResponse(BaseModel):
},
)
async def parse_dynamicprompts(
+ current_user: CurrentUserOrDefault,
prompt: str = Body(description="The prompt to parse with dynamicprompts"),
max_prompts: int = Body(ge=1, le=10000, default=1000, description="The max number of prompts to generate"),
combinatorial: bool = Body(default=True, description="Whether to use the combinatorial generator"),
+ seed: int | None = Body(None, description="The seed to use for random generation. Only used if not combinatorial"),
) -> DynamicPromptsResponse:
"""Creates a batch process"""
max_prompts = min(max_prompts, 10000)
@@ -35,9 +58,170 @@ async def parse_dynamicprompts(
generator = CombinatorialPromptGenerator()
prompts = generator.generate(prompt, max_prompts=max_prompts)
else:
- generator = RandomPromptGenerator()
+ generator = RandomPromptGenerator(seed=seed)
prompts = generator.generate(prompt, num_images=max_prompts)
except ParseException as e:
prompts = [prompt]
error = str(e)
return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error)
+
+
+# --- Expand Prompt ---
+
+
+class ExpandPromptRequest(BaseModel):
+ prompt: str
+ model_key: str
+ max_tokens: int = Field(default=300, ge=1, le=2048)
+ system_prompt: str | None = None
+
+
+class ExpandPromptResponse(BaseModel):
+ expanded_prompt: str
+ error: str | None = None
+
+
+def _resolve_model_path(model_config_path: str) -> Path:
+ """Resolve a model config path to an absolute path."""
+ model_path = Path(model_config_path)
+ if model_path.is_absolute():
+ return model_path.resolve()
+ base_models_path = ApiDependencies.invoker.services.configuration.models_path
+ return (base_models_path / model_path).resolve()
+
+
+def _run_expand_prompt(prompt: str, model_key: str, max_tokens: int, system_prompt: str | None) -> str:
+ """Run text LLM inference synchronously (called from thread)."""
+ model_manager = ApiDependencies.invoker.services.model_manager
+ model_config = model_manager.store.get_model(model_key)
+
+ if model_config.type != ModelType.TextLLM:
+ raise ValueError(f"Model '{model_key}' is not a TextLLM model (got {model_config.type})")
+
+ with _model_load_lock:
+ loaded_model = model_manager.load.load_model(model_config)
+
+ with torch.no_grad(), loaded_model.model_on_device() as (_, model):
+ model_abs_path = _resolve_model_path(model_config.path)
+ tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True)
+
+ pipeline = TextLLMPipeline(model, tokenizer)
+ model_device = next(model.parameters()).device
+ output = pipeline.run(
+ prompt=prompt,
+ system_prompt=system_prompt or DEFAULT_SYSTEM_PROMPT,
+ max_new_tokens=max_tokens,
+ device=model_device,
+ dtype=TorchDevice.choose_torch_dtype(),
+ )
+
+ return output
+
+
+@utilities_router.post(
+ "/expand-prompt",
+ operation_id="expand_prompt",
+ responses={
+ 200: {"model": ExpandPromptResponse},
+ },
+)
+async def expand_prompt(current_user: CurrentUserOrDefault, body: ExpandPromptRequest) -> ExpandPromptResponse:
+ """Expand a brief prompt into a detailed image generation prompt using a text LLM."""
+ try:
+ expanded = await asyncio.to_thread(
+ _run_expand_prompt,
+ body.prompt,
+ body.model_key,
+ body.max_tokens,
+ body.system_prompt,
+ )
+ return ExpandPromptResponse(expanded_prompt=expanded)
+ except UnknownModelException:
+ raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found")
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e))
+ except Exception as e:
+ logger.error(f"Error expanding prompt: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# --- Image to Prompt ---
+
+
+class ImageToPromptRequest(BaseModel):
+ image_name: str
+ model_key: str
+ instruction: str = "Describe this image in detail for use as an AI image generation prompt."
+
+
+class ImageToPromptResponse(BaseModel):
+ prompt: str
+ error: str | None = None
+
+
+def _run_image_to_prompt(image_name: str, model_key: str, instruction: str) -> str:
+ """Run LLaVA OneVision inference synchronously (called from thread)."""
+ model_manager = ApiDependencies.invoker.services.model_manager
+ model_config = model_manager.store.get_model(model_key)
+
+ if model_config.type != ModelType.LlavaOnevision:
+ raise ValueError(f"Model '{model_key}' is not a LLaVA OneVision model (got {model_config.type})")
+
+ with _model_load_lock:
+ loaded_model = model_manager.load.load_model(model_config)
+
+ # Load the image from InvokeAI's image store
+ image = ApiDependencies.invoker.services.images.get_pil_image(image_name)
+ image = image.convert("RGB")
+
+ with torch.no_grad(), loaded_model.model_on_device() as (_, model):
+ if not isinstance(model, LlavaOnevisionForConditionalGeneration):
+ raise TypeError(f"Expected LlavaOnevisionForConditionalGeneration, got {type(model).__name__}")
+
+ model_abs_path = _resolve_model_path(model_config.path)
+ processor = AutoProcessor.from_pretrained(model_abs_path, local_files_only=True)
+ if not isinstance(processor, LlavaOnevisionProcessor):
+ raise TypeError(f"Expected LlavaOnevisionProcessor, got {type(processor).__name__}")
+
+ pipeline = LlavaOnevisionPipeline(model, processor)
+ model_device = next(model.parameters()).device
+ output = pipeline.run(
+ prompt=instruction,
+ images=[image],
+ device=model_device,
+ dtype=TorchDevice.choose_torch_dtype(),
+ )
+
+ return output
+
+
+@utilities_router.post(
+ "/image-to-prompt",
+ operation_id="image_to_prompt",
+ responses={
+ 200: {"model": ImageToPromptResponse},
+ },
+)
+async def image_to_prompt(current_user: CurrentUserOrDefault, body: ImageToPromptRequest) -> ImageToPromptResponse:
+ """Generate a descriptive prompt from an image using a vision-language model."""
+ # Reuse the image-read access check so non-owners can't probe stored images
+ # via this endpoint (mirrors the policy in routers/images.py).
+ assert_image_read_access(body.image_name, current_user)
+
+ try:
+ prompt = await asyncio.to_thread(
+ _run_image_to_prompt,
+ body.image_name,
+ body.model_key,
+ body.instruction,
+ )
+ return ImageToPromptResponse(prompt=prompt)
+ except UnknownModelException:
+ raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found")
+ except ImageFileNotFoundException:
+ raise HTTPException(status_code=404, detail=f"Image '{body.image_name}' not found")
+ except (ValueError, TypeError) as e:
+ raise HTTPException(status_code=422, detail=str(e))
+ except Exception as e:
+ logger.error(f"Error generating prompt from image: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/invokeai/app/api/routers/virtual_boards.py b/invokeai/app/api/routers/virtual_boards.py
new file mode 100644
index 00000000000..f0c9e2edc51
--- /dev/null
+++ b/invokeai/app/api/routers/virtual_boards.py
@@ -0,0 +1,56 @@
+from fastapi import HTTPException, Path, Query
+from fastapi.routing import APIRouter
+
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
+
+virtual_boards_router = APIRouter(prefix="/v1/virtual_boards", tags=["virtual_boards"])
+
+
+@virtual_boards_router.get(
+ "/by_date",
+ operation_id="list_virtual_boards_by_date",
+ response_model=list[VirtualSubBoardDTO],
+)
+async def list_virtual_boards_by_date(
+ current_user: CurrentUserOrDefault,
+) -> list[VirtualSubBoardDTO]:
+ """Gets a list of virtual sub-boards grouped by date."""
+ try:
+ return ApiDependencies.invoker.services.image_records.get_image_dates(
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get virtual boards by date")
+
+
+@virtual_boards_router.get(
+ "/by_date/{date}/image_names",
+ operation_id="list_virtual_board_image_names_by_date",
+ response_model=ImageNamesResult,
+)
+async def list_virtual_board_image_names_by_date(
+ current_user: CurrentUserOrDefault,
+ date: str = Path(description="The ISO date string, e.g. '2026-03-18'"),
+ starred_first: bool = Query(default=True, description="Whether to sort starred images first"),
+ order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"),
+ categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"),
+ search_term: str | None = Query(default=None, description="Search term to filter images"),
+) -> ImageNamesResult:
+ """Gets ordered image names for a specific date."""
+ try:
+ return ApiDependencies.invoker.services.image_records.get_image_names_by_date(
+ date=date,
+ starred_first=starred_first,
+ order_dir=order_dir,
+ categories=categories,
+ search_term=search_term,
+ user_id=current_user.user_id,
+ is_admin=current_user.is_admin,
+ )
+ except Exception:
+ raise HTTPException(status_code=500, detail="Failed to get image names for date")
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 6e93d6d0ce5..eb893251953 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -1,7 +1,12 @@
+import io
+import traceback
from typing import Optional
-from fastapi import APIRouter, Body, HTTPException, Path, Query
+from fastapi import APIRouter, Body, File, HTTPException, Path, Query, UploadFile
+from fastapi.responses import FileResponse
+from PIL import Image
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
@@ -10,11 +15,14 @@
WorkflowCategory,
WorkflowNotFoundError,
WorkflowRecordDTO,
- WorkflowRecordListItemDTO,
+ WorkflowRecordListItemWithThumbnailDTO,
WorkflowRecordOrderBy,
+ WorkflowRecordWithThumbnailDTO,
WorkflowWithoutID,
)
+from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import WorkflowThumbnailFileNotFoundException
+IMAGE_MAX_AGE = 31536000
workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"])
@@ -22,18 +30,29 @@
"/i/{workflow_id}",
operation_id="get_workflow",
responses={
- 200: {"model": WorkflowRecordDTO},
+ 200: {"model": WorkflowRecordWithThumbnailDTO},
},
)
async def get_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to get"),
-) -> WorkflowRecordDTO:
+) -> WorkflowRecordWithThumbnailDTO:
"""Gets a workflow"""
try:
- return ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ is_default = workflow.workflow.meta.category is WorkflowCategory.Default
+ is_owner = workflow.user_id == current_user.user_id
+ if not (is_default or is_owner or workflow.is_public or current_user.is_admin):
+ raise HTTPException(status_code=403, detail="Not authorized to access this workflow")
+
+ thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
+ return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
+
@workflows_router.patch(
"/i/{workflow_id}",
@@ -43,10 +62,21 @@ async def get_workflow(
},
)
async def update_workflow(
+ current_user: CurrentUserOrDefault,
workflow: Workflow = Body(description="The updated workflow", embed=True),
) -> WorkflowRecordDTO:
"""Updates a workflow"""
- return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow.id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+ # Pass user_id for defense-in-depth SQL scoping; admins pass None to allow any.
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow, user_id=user_id)
@workflows_router.delete(
@@ -54,10 +84,25 @@ async def update_workflow(
operation_id="delete_workflow",
)
async def delete_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to delete"),
) -> None:
"""Deletes a workflow"""
- ApiDependencies.invoker.services.workflow_records.delete(workflow_id)
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to delete this workflow")
+ try:
+ ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
+ except WorkflowThumbnailFileNotFoundException:
+ # It's OK if the workflow has no thumbnail file. We can still delete the workflow.
+ pass
+ user_id = None if current_user.is_admin else current_user.user_id
+ ApiDependencies.invoker.services.workflow_records.delete(workflow_id, user_id=user_id)
@workflows_router.post(
@@ -68,30 +113,287 @@ async def delete_workflow(
},
)
async def create_workflow(
+ current_user: CurrentUserOrDefault,
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
) -> WorkflowRecordDTO:
"""Creates a workflow"""
- return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow)
+ # In single-user mode, workflows are owned by 'system' and shared by default so all legacy/single-user
+ # workflows remain visible. In multiuser mode, workflows are private to the creator by default.
+ config = ApiDependencies.invoker.services.configuration
+ is_public = not config.multiuser
+ return ApiDependencies.invoker.services.workflow_records.create(
+ workflow=workflow, user_id=current_user.user_id, is_public=is_public
+ )
@workflows_router.get(
"/",
operation_id="list_workflows",
responses={
- 200: {"model": PaginatedResults[WorkflowRecordListItemDTO]},
+ 200: {"model": PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]},
},
)
async def list_workflows(
+ current_user: CurrentUserOrDefault,
page: int = Query(default=0, description="The page to get"),
- per_page: int = Query(default=10, description="The number of workflows per page"),
+ per_page: Optional[int] = Query(default=None, description="The number of workflows per page"),
order_by: WorkflowRecordOrderBy = Query(
default=WorkflowRecordOrderBy.Name, description="The attribute to order by"
),
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
- category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"),
+ categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"),
+ tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
-) -> PaginatedResults[WorkflowRecordListItemDTO]:
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
+) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
- return ApiDependencies.invoker.services.workflow_records.get_many(
- page=page, per_page=per_page, order_by=order_by, direction=direction, query=query, category=category
+ config = ApiDependencies.invoker.services.configuration
+
+ # In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows.
+ # Admins skip the user_id filter so they can see and manage all workflows including system-owned ones.
+ user_id_filter: Optional[str] = None
+ if config.multiuser and not current_user.is_admin:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
+ workflows = ApiDependencies.invoker.services.workflow_records.get_many(
+ order_by=order_by,
+ direction=direction,
+ page=page,
+ per_page=per_page,
+ query=query,
+ categories=categories,
+ tags=tags,
+ has_been_opened=has_been_opened,
+ user_id=user_id_filter,
+ is_public=is_public,
+ )
+ for workflow in workflows.items:
+ workflows_with_thumbnails.append(
+ WorkflowRecordListItemWithThumbnailDTO(
+ thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id),
+ **workflow.model_dump(),
+ )
+ )
+ return PaginatedResults[WorkflowRecordListItemWithThumbnailDTO](
+ items=workflows_with_thumbnails,
+ total=workflows.total,
+ page=workflows.page,
+ pages=workflows.pages,
+ per_page=workflows.per_page,
+ )
+
+
+@workflows_router.put(
+ "/i/{workflow_id}/thumbnail",
+ operation_id="set_workflow_thumbnail",
+ responses={
+ 200: {"model": WorkflowRecordDTO},
+ },
+)
+async def set_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+ image: UploadFile = File(description="The image file to upload"),
+):
+ """Sets a workflow's thumbnail image"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ if not image.content_type or not image.content_type.startswith("image"):
+ raise HTTPException(status_code=415, detail="Not an image")
+
+ contents = await image.read()
+ try:
+ pil_image = Image.open(io.BytesIO(contents))
+
+ except Exception:
+ ApiDependencies.invoker.services.logger.error(traceback.format_exc())
+ raise HTTPException(status_code=415, detail="Failed to read image")
+
+ try:
+ ApiDependencies.invoker.services.workflow_thumbnails.save(workflow_id, pil_image)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@workflows_router.delete(
+ "/i/{workflow_id}/thumbnail",
+ operation_id="delete_workflow_thumbnail",
+ responses={
+ 200: {"model": WorkflowRecordDTO},
+ },
+)
+async def delete_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+):
+ """Removes a workflow's thumbnail image"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ try:
+ ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
+ except ValueError as e:
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@workflows_router.get(
+ "/i/{workflow_id}/thumbnail",
+ operation_id="get_workflow_thumbnail",
+ responses={
+ 200: {
+ "description": "The workflow thumbnail was fetched successfully",
+ },
+ 400: {"description": "Bad request"},
+ 404: {"description": "The workflow thumbnail could not be found"},
+ },
+ status_code=200,
+)
+async def get_workflow_thumbnail(
+ workflow_id: str = Path(description="The id of the workflow thumbnail to get"),
+) -> FileResponse:
+ """Gets a workflow's thumbnail image.
+
+ This endpoint is intentionally unauthenticated because browsers load images
+ via tags which cannot send Bearer tokens. Workflow IDs are UUIDs,
+ providing security through unguessability.
+ """
+ try:
+ path = ApiDependencies.invoker.services.workflow_thumbnails.get_path(workflow_id)
+
+ response = FileResponse(
+ path,
+ media_type="image/png",
+ filename=workflow_id + ".png",
+ content_disposition_type="inline",
+ )
+ response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
+ return response
+ except Exception:
+ raise HTTPException(status_code=404)
+
+
+@workflows_router.patch(
+ "/i/{workflow_id}/is_public",
+ operation_id="update_workflow_is_public",
+ responses={
+ 200: {"model": WorkflowRecordDTO},
+ },
+)
+async def update_workflow_is_public(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+ is_public: bool = Body(description="Whether the workflow should be shared publicly", embed=True),
+) -> WorkflowRecordDTO:
+ """Updates whether a workflow is shared publicly"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ user_id = None if current_user.is_admin else current_user.user_id
+ return ApiDependencies.invoker.services.workflow_records.update_is_public(
+ workflow_id=workflow_id, is_public=is_public, user_id=user_id
)
+
+
+@workflows_router.get("/tags", operation_id="get_all_tags")
+async def get_all_tags(
+ current_user: CurrentUserOrDefault,
+ categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
+) -> list[str]:
+ """Gets all unique tags from workflows"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser and not current_user.is_admin:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ return ApiDependencies.invoker.services.workflow_records.get_all_tags(
+ categories=categories, user_id=user_id_filter, is_public=is_public
+ )
+
+
+@workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag")
+async def get_counts_by_tag(
+ current_user: CurrentUserOrDefault,
+ tags: list[str] = Query(description="The tags to get counts for"),
+ categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
+) -> dict[str, int]:
+ """Counts workflows by tag"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser and not current_user.is_admin:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ return ApiDependencies.invoker.services.workflow_records.counts_by_tag(
+ tags=tags, categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
+ )
+
+
+@workflows_router.get("/counts_by_category", operation_id="counts_by_category")
+async def counts_by_category(
+ current_user: CurrentUserOrDefault,
+ categories: list[WorkflowCategory] = Query(description="The categories to include"),
+ has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
+) -> dict[str, int]:
+ """Counts workflows by category"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser and not current_user.is_admin:
+ has_user_category = WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ return ApiDependencies.invoker.services.workflow_records.counts_by_category(
+ categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
+ )
+
+
+@workflows_router.put(
+ "/i/{workflow_id}/opened_at",
+ operation_id="update_opened_at",
+)
+async def update_opened_at(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+) -> None:
+ """Updates the opened_at field of a workflow"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ user_id = None if current_user.is_admin else current_user.user_id
+ ApiDependencies.invoker.services.workflow_records.update_opened_at(workflow_id, user_id=user_id)
diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py
index b39922c69bf..7e93c332d64 100644
--- a/invokeai/app/api/sockets.py
+++ b/invokeai/app/api/sockets.py
@@ -6,6 +6,7 @@
from pydantic import BaseModel
from socketio import ASGIApp, AsyncServer
+from invokeai.app.services.auth.token_service import verify_token
from invokeai.app.services.events.events_common import (
BatchEnqueuedEvent,
BulkDownloadCompleteEvent,
@@ -20,8 +21,8 @@
DownloadStartedEvent,
FastAPIEvent,
InvocationCompleteEvent,
- InvocationDenoiseProgressEvent,
InvocationErrorEvent,
+ InvocationProgressEvent,
InvocationStartedEvent,
ModelEventBase,
ModelInstallCancelledEvent,
@@ -35,8 +36,12 @@
QueueClearedEvent,
QueueEventBase,
QueueItemStatusChangedEvent,
+ RecallParametersUpdatedEvent,
register_events,
)
+from invokeai.backend.util.logging import InvokeAILogger
+
+logger = InvokeAILogger.get_logger()
class QueueSubscriptionEvent(BaseModel):
@@ -55,12 +60,13 @@ class BulkDownloadSubscriptionEvent(BaseModel):
QUEUE_EVENTS = {
InvocationStartedEvent,
- InvocationDenoiseProgressEvent,
+ InvocationProgressEvent,
InvocationCompleteEvent,
InvocationErrorEvent,
QueueItemStatusChangedEvent,
BatchEnqueuedEvent,
QueueClearedEvent,
+ RecallParametersUpdatedEvent,
}
MODEL_EVENTS = {
@@ -94,6 +100,13 @@ def __init__(self, app: FastAPI):
self._app = ASGIApp(socketio_server=self._sio, socketio_path="/ws/socket.io")
app.mount("/ws", self._app)
+ # Track user information for each socket connection
+ self._socket_users: dict[str, dict[str, Any]] = {}
+
+ # Set up authentication middleware
+ self._sio.on("connect", handler=self._handle_connect)
+ self._sio.on("disconnect", handler=self._handle_disconnect)
+
self._sio.on(self._sub_queue, handler=self._handle_sub_queue)
self._sio.on(self._unsub_queue, handler=self._handle_unsub_queue)
self._sio.on(self._sub_bulk_download, handler=self._handle_sub_bulk_download)
@@ -103,23 +116,258 @@ def __init__(self, app: FastAPI):
register_events(MODEL_EVENTS, self._handle_model_event)
register_events(BULK_DOWNLOAD_EVENTS, self._handle_bulk_image_download_event)
+ async def _handle_connect(self, sid: str, environ: dict, auth: dict | None) -> bool:
+ """Handle socket connection and authenticate the user.
+
+ Returns True to accept the connection, False to reject it.
+ Stores user_id in the internal socket users dict for later use.
+
+ In multiuser mode, connections without a valid token are rejected outright
+ so that anonymous clients cannot subscribe to queue rooms and observe
+ queue activity belonging to other users. In single-user mode, unauthenticated
+ connections are accepted as the system admin user.
+ """
+ # Extract token from auth data or headers
+ token = None
+ if auth and isinstance(auth, dict):
+ token = auth.get("token")
+
+ if not token and environ:
+ # Try to get token from headers
+ headers = environ.get("HTTP_AUTHORIZATION", "")
+ if headers.startswith("Bearer "):
+ token = headers[7:]
+
+ # Verify the token
+ if token:
+ token_data = verify_token(token)
+ if token_data:
+ # In multiuser mode, also verify the backing user record still
+ # exists and is active — mirrors the REST auth check in
+ # auth_dependencies.py. A deleted or deactivated user whose
+ # JWT has not yet expired must not be allowed to open a socket.
+ if self._is_multiuser_enabled():
+ try:
+ from invokeai.app.api.dependencies import ApiDependencies
+
+ user = ApiDependencies.invoker.services.users.get(token_data.user_id)
+ if user is None or not user.is_active:
+ logger.warning(f"Rejecting socket {sid}: user {token_data.user_id} not found or inactive")
+ return False
+ except Exception:
+ # If user service is unavailable, fail closed
+ logger.warning(f"Rejecting socket {sid}: unable to verify user record")
+ return False
+
+ # Store user_id and is_admin in socket users dict
+ self._socket_users[sid] = {
+ "user_id": token_data.user_id,
+ "is_admin": token_data.is_admin,
+ }
+ logger.info(
+ f"Socket {sid} connected with user_id: {token_data.user_id}, is_admin: {token_data.is_admin}"
+ )
+ return True
+
+ # No valid token provided. In multiuser mode this is not allowed — reject
+ # the connection so anonymous clients cannot subscribe to queue rooms.
+ # In single-user mode, fall through and accept the socket as system admin.
+ if self._is_multiuser_enabled():
+ logger.warning(
+ f"Rejecting socket {sid} connection: multiuser mode is enabled and no valid auth token was provided"
+ )
+ return False
+
+ self._socket_users[sid] = {
+ "user_id": "system",
+ "is_admin": True,
+ }
+ logger.debug(f"Socket {sid} connected as system admin (single-user mode)")
+ return True
+
+ @staticmethod
+ def _is_multiuser_enabled() -> bool:
+ """Check whether multiuser mode is enabled. Fails closed if configuration
+ is not yet initialized, which should not happen in practice but prevents
+ accidentally opening the socket during startup races."""
+ try:
+ # Imported here to avoid a circular import at module load time.
+ from invokeai.app.api.dependencies import ApiDependencies
+
+ return bool(ApiDependencies.invoker.services.configuration.multiuser)
+ except Exception:
+ # If dependencies are not initialized, fail closed (treat as multiuser)
+ # so we never accidentally admit an anonymous socket.
+ return True
+
+ async def _handle_disconnect(self, sid: str) -> None:
+ """Handle socket disconnection and cleanup user info."""
+ if sid in self._socket_users:
+ del self._socket_users[sid]
+ logger.debug(f"Socket {sid} disconnected and cleaned up")
+
async def _handle_sub_queue(self, sid: str, data: Any) -> None:
- await self._sio.enter_room(sid, QueueSubscriptionEvent(**data).queue_id)
+ """Handle queue subscription and add socket to both queue and user-specific rooms."""
+ queue_id = QueueSubscriptionEvent(**data).queue_id
+
+ # Check if we have user info for this socket. In multiuser mode _handle_connect
+ # will have already rejected any socket without a valid token, so missing user
+ # info here is a bug — refuse the subscription rather than silently falling back
+ # to an anonymous system user who could then receive queue item events.
+ if sid not in self._socket_users:
+ if self._is_multiuser_enabled():
+ logger.warning(
+ f"Refusing queue subscription for socket {sid}: no user info (socket not authenticated via connect event)"
+ )
+ return
+ # Single-user mode: safe to fall back to the system admin user.
+ self._socket_users[sid] = {
+ "user_id": "system",
+ "is_admin": True,
+ }
+
+ user_id = self._socket_users[sid]["user_id"]
+ is_admin = self._socket_users[sid]["is_admin"]
+
+ # Add socket to the queue room
+ await self._sio.enter_room(sid, queue_id)
+
+ # Also add socket to a user-specific room for event filtering
+ user_room = f"user:{user_id}"
+ await self._sio.enter_room(sid, user_room)
+
+ # If admin, also add to admin room to receive all events
+ if is_admin:
+ await self._sio.enter_room(sid, "admin")
+
+ logger.debug(
+ f"Socket {sid} (user_id: {user_id}, is_admin: {is_admin}) subscribed to queue {queue_id} and user room {user_room}"
+ )
async def _handle_unsub_queue(self, sid: str, data: Any) -> None:
await self._sio.leave_room(sid, QueueSubscriptionEvent(**data).queue_id)
async def _handle_sub_bulk_download(self, sid: str, data: Any) -> None:
+ # In multiuser mode, only allow authenticated sockets to subscribe.
+ # Bulk download events are routed to user-specific rooms, so the
+ # bulk_download_id room subscription is only kept for single-user
+ # backward compatibility.
+ if self._is_multiuser_enabled() and sid not in self._socket_users:
+ logger.warning(f"Refusing bulk download subscription for unknown socket {sid} in multiuser mode")
+ return
await self._sio.enter_room(sid, BulkDownloadSubscriptionEvent(**data).bulk_download_id)
async def _handle_unsub_bulk_download(self, sid: str, data: Any) -> None:
await self._sio.leave_room(sid, BulkDownloadSubscriptionEvent(**data).bulk_download_id)
async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]):
- await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json"), room=event[1].queue_id)
+ """Handle queue events with user isolation.
+
+ All queue item events (invocation events AND QueueItemStatusChangedEvent) are
+ private to the owning user and admins. They carry unsanitized user_id, batch_id,
+ session_id, origin, destination and error metadata, and must never be broadcast
+ to the whole queue room — otherwise any other authenticated subscriber could
+ observe cross-user queue activity.
+
+ RecallParametersUpdatedEvent is also private to the owner + admins.
+
+ BatchEnqueuedEvent carries the enqueuing user's batch_id/origin/counts and
+ is also routed privately. QueueClearedEvent is the only queue event that
+ is still broadcast to the whole queue room.
+
+ IMPORTANT: Check InvocationEventBase BEFORE QueueItemEventBase since InvocationEventBase
+ inherits from QueueItemEventBase. The order of isinstance checks matters!
+ """
+ try:
+ event_name, event_data = event
+
+ # Import here to avoid circular dependency
+ from invokeai.app.services.events.events_common import InvocationEventBase, QueueItemEventBase
+
+ # Check InvocationEventBase FIRST (before QueueItemEventBase) since it's a subclass
+ # Invocation events (progress, started, complete, error) are private to owner + admins
+ if isinstance(event_data, InvocationEventBase) and hasattr(event_data, "user_id"):
+ user_room = f"user:{event_data.user_id}"
+
+ # Emit to the user's room
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
+
+ # Also emit to admin room so admins can see all events, but strip image preview data
+ # from InvocationProgressEvent to prevent admins from seeing other users' image content
+ if isinstance(event_data, InvocationProgressEvent):
+ admin_event_data = event_data.model_copy(update={"image": None})
+ await self._sio.emit(event=event_name, data=admin_event_data.model_dump(mode="json"), room="admin")
+ else:
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
+
+ logger.debug(f"Emitted private invocation event {event_name} to user room {user_room} and admin room")
+
+ # Other queue item events (QueueItemStatusChangedEvent) carry unsanitized
+ # user_id, batch_id, session_id, origin, destination and error metadata.
+ # They are private to the owning user + admins — never broadcast to the
+ # full queue room.
+ elif isinstance(event_data, QueueItemEventBase) and hasattr(event_data, "user_id"):
+ user_room = f"user:{event_data.user_id}"
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
+
+ logger.debug(f"Emitted private queue item event {event_name} to user room {user_room} and admin room")
+
+ # RecallParametersUpdatedEvent is private - only emit to owner + admins.
+ #
+ # Emit to the union of the owner room and the admin room in a SINGLE
+ # call. python-socketio deduplicates recipients across a room list,
+ # so a socket that belongs to BOTH rooms — e.g. the "system" user in
+ # single-user mode, which is also an admin — receives the event
+ # exactly once. Two separate emits would deliver it twice: harmless
+ # for the idempotent scalar recall fields (the frontend just re-sets
+ # them), but the append-mode reference-image recall *pushes* rather
+ # than replaces, so a double delivery adds the same reference image
+ # twice.
+ elif isinstance(event_data, RecallParametersUpdatedEvent):
+ user_room = f"user:{event_data.user_id}"
+ await self._sio.emit(
+ event=event_name, data=event_data.model_dump(mode="json"), room=[user_room, "admin"]
+ )
+ logger.debug(f"Emitted private recall_parameters_updated event to user room {user_room} and admin room")
+
+ # BatchEnqueuedEvent carries the enqueuing user's batch_id, origin, and
+ # enqueued counts. Route it privately to the owner + admins so other
+ # users do not observe cross-user batch activity.
+ elif isinstance(event_data, BatchEnqueuedEvent):
+ user_room = f"user:{event_data.user_id}"
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
+ logger.debug(f"Emitted private batch_enqueued event to user room {user_room} and admin room")
+
+ else:
+ # For remaining queue events (e.g. QueueClearedEvent) that do not
+ # carry user identity, emit to all subscribers in the queue room.
+ await self._sio.emit(
+ event=event_name, data=event_data.model_dump(mode="json"), room=event_data.queue_id
+ )
+ logger.debug(
+ f"Emitted general queue event {event_name} to all subscribers in queue {event_data.queue_id}"
+ )
+ except Exception as e:
+ # Log any unhandled exceptions in event handling to prevent silent failures
+ logger.error(f"Error handling queue event {event[0]}: {e}", exc_info=True)
async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | DownloadEventBase]) -> None:
await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json"))
async def _handle_bulk_image_download_event(self, event: FastAPIEvent[BulkDownloadEventBase]) -> None:
- await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json"), room=event[1].bulk_download_id)
+ event_name, event_data = event
+ # Route to user-specific + admin rooms so that other authenticated
+ # users cannot learn the bulk_download_item_name (the capability token
+ # needed to fetch the zip from the unauthenticated GET endpoint).
+ # In single-user mode (user_id="system"), fall back to the shared
+ # bulk_download_id room for backward compatibility.
+ if hasattr(event_data, "user_id") and event_data.user_id != "system":
+ user_room = f"user:{event_data.user_id}"
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room)
+ await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin")
+ else:
+ await self._sio.emit(
+ event=event_name, data=event_data.model_dump(mode="json"), room=event_data.bulk_download_id
+ )
diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py
index e69d95af714..4b79e1eeb0c 100644
--- a/invokeai/app/api_app.py
+++ b/invokeai/app/api_app.py
@@ -1,66 +1,70 @@
import asyncio
import logging
-import mimetypes
-import socket
from contextlib import asynccontextmanager
from pathlib import Path
-import torch
-import uvicorn
-from fastapi import FastAPI
+from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi_events.handlers.local import local_handler
from fastapi_events.middleware import EventHandlerASGIMiddleware
-from torch.backends.mps import is_available as is_mps_available
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
-# for PyCharm:
-# noinspection PyUnresolvedReferences
-import invokeai.backend.util.hotfixes # noqa: F401 (monkeypatching on import)
import invokeai.frontend.web as web_dir
+from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles
-from invokeai.app.services.config.config_default import get_config
-from invokeai.app.util.custom_openapi import get_openapi_func
-from invokeai.backend.util.devices import TorchDevice
-
-from ..backend.util.logging import InvokeAILogger
-from .api.dependencies import ApiDependencies
-from .api.routers import (
+from invokeai.app.api.routers import (
app_info,
+ auth,
board_images,
boards,
+ client_state,
+ custom_nodes,
download_queue,
images,
model_manager,
+ model_relationships,
+ recall_parameters,
session_queue,
+ style_presets,
utilities,
+ virtual_boards,
workflows,
)
-from .api.sockets import SocketIO
+from invokeai.app.api.sockets import SocketIO
+from invokeai.app.services.config.config_default import get_config
+from invokeai.app.util.custom_openapi import get_openapi_func
+from invokeai.backend.util.logging import InvokeAILogger
app_config = get_config()
-
-
-if is_mps_available():
- import invokeai.backend.util.mps_fixes # noqa: F401 (monkeypatching on import)
-
-
logger = InvokeAILogger.get_logger(config=app_config)
-# fix for windows mimetypes registry entries being borked
-# see https://github.com/invoke-ai/InvokeAI/discussions/3684#discussioncomment-6391352
-mimetypes.add_type("application/javascript", ".js")
-mimetypes.add_type("text/css", ".css")
-torch_device_name = TorchDevice.get_torch_device_name()
-logger.info(f"Using torch device: {torch_device_name}")
+loop = asyncio.new_event_loop()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Add startup event to load dependencies
- ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, logger=logger)
+ ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, loop=loop, logger=logger)
+
+ # Log the server address when it starts - in case the network log level is not high enough to see the startup log
+ proto = "https" if app_config.ssl_certfile else "http"
+ msg = f"Invoke running on {proto}://{app_config.host}:{app_config.port} (Press CTRL+C to quit)"
+
+ # Logging this way ignores the logger's log level and _always_ logs the message
+ record = logger.makeRecord(
+ name=logger.name,
+ level=logging.INFO,
+ fn="",
+ lno=0,
+ msg=msg,
+ args=(),
+ exc_info=None,
+ )
+ logger.handle(record)
+
yield
# Shut down threads
ApiDependencies.shutdown()
@@ -76,6 +80,74 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)
+
+class SlidingWindowTokenMiddleware(BaseHTTPMiddleware):
+ """Refresh the JWT token on each authenticated response.
+
+ When a request includes a valid Bearer token, the response includes a
+ X-Refreshed-Token header with a new token that has a fresh expiry.
+ This implements sliding-window session expiry: the session only expires
+ after a period of *inactivity*, not a fixed time after login.
+ """
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
+ response = await call_next(request)
+
+ # Only refresh on mutating requests (POST/PUT/PATCH/DELETE) — these indicate
+ # genuine user activity. GET requests are often background fetches (RTK Query
+ # cache revalidation, refetch-on-focus, etc.) and should not reset the
+ # inactivity timer.
+ if response.status_code < 400 and request.method in ("POST", "PUT", "PATCH", "DELETE"):
+ auth_header = request.headers.get("authorization", "")
+ if auth_header.startswith("Bearer "):
+ token = auth_header[7:]
+ try:
+ from datetime import timedelta
+
+ from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL, TOKEN_EXPIRATION_REMEMBER_ME
+ from invokeai.app.services.auth.token_service import create_access_token, verify_token
+
+ token_data = verify_token(token)
+ if token_data is not None:
+ # Use the remember_me claim from the token to determine the
+ # correct refresh duration. This avoids the bug where a 7-day
+ # token with <24h remaining would be silently downgraded to 1 day.
+ if token_data.remember_me:
+ expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME)
+ else:
+ expires_delta = timedelta(days=TOKEN_EXPIRATION_NORMAL)
+
+ new_token = create_access_token(token_data, expires_delta)
+ response.headers["X-Refreshed-Token"] = new_token
+ except Exception:
+ pass # Don't fail the request if token refresh fails
+
+ return response
+
+
+class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware):
+ """When a request is made to the root path with a query string, redirect to the root path without the query string.
+
+ For example, to force a Gradio app to use dark mode, users may append `?__theme=dark` to the URL. Their browser may
+ have this query string saved in history or a bookmark, so when the user navigates to `http://127.0.0.1:9090/`, the
+ browser takes them to `http://127.0.0.1:9090/?__theme=dark`.
+
+ This breaks the static file serving in the UI, so we redirect the user to the root path without the query string.
+ """
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
+ if request.url.path == "/" and request.url.query:
+ return RedirectResponse(url="/")
+
+ response = await call_next(request)
+ return response
+
+
+# Add the middleware
+app.add_middleware(RedirectRootWithQueryStringMiddleware)
+app.add_middleware(SlidingWindowTokenMiddleware)
+
+
# Add event handler
event_handler_id: int = id(app)
app.add_middleware(
@@ -92,21 +164,30 @@ async def lifespan(app: FastAPI):
allow_credentials=app_config.allow_credentials,
allow_methods=app_config.allow_methods,
allow_headers=app_config.allow_headers,
+ expose_headers=["X-Refreshed-Token"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Include all routers
+# Authentication router should be first so it's registered before protected routes
+app.include_router(auth.auth_router, prefix="/api")
app.include_router(utilities.utilities_router, prefix="/api")
app.include_router(model_manager.model_manager_router, prefix="/api")
app.include_router(download_queue.download_queue_router, prefix="/api")
app.include_router(images.images_router, prefix="/api")
app.include_router(boards.boards_router, prefix="/api")
app.include_router(board_images.board_images_router, prefix="/api")
+app.include_router(virtual_boards.virtual_boards_router, prefix="/api")
+app.include_router(model_relationships.model_relationships_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")
app.include_router(workflows.workflows_router, prefix="/api")
+app.include_router(style_presets.style_presets_router, prefix="/api")
+app.include_router(client_state.client_state_router, prefix="/api")
+app.include_router(recall_parameters.recall_parameters_router, prefix="/api")
+app.include_router(custom_nodes.custom_nodes_router, prefix="/api")
app.openapi = get_openapi_func(app)
@@ -131,81 +212,16 @@ def overridden_redoc() -> HTMLResponse:
web_root_path = Path(list(web_dir.__path__)[0])
+if app_config.unsafe_disable_picklescan:
+ logger.warning(
+ "The unsafe_disable_picklescan option is enabled. This disables malware scanning while installing and"
+ "loading models, which may allow malicious code to be executed. Use at your own risk."
+ )
+
try:
app.mount("/", NoCacheStaticFiles(directory=Path(web_root_path, "dist"), html=True), name="ui")
except RuntimeError:
- logger.warn(f"No UI found at {web_root_path}/dist, skipping UI mount")
+ logger.warning(f"No UI found at {web_root_path}/dist, skipping UI mount")
app.mount(
"/static", NoCacheStaticFiles(directory=Path(web_root_path, "static/")), name="static"
) # docs favicon is in here
-
-
-def check_cudnn(logger: logging.Logger) -> None:
- """Check for cuDNN issues that could be causing degraded performance."""
- if torch.backends.cudnn.is_available():
- try:
- # Note: At the time of writing (torch 2.2.1), torch.backends.cudnn.version() only raises an error the first
- # time it is called. Subsequent calls will return the version number without complaining about a mismatch.
- cudnn_version = torch.backends.cudnn.version()
- logger.info(f"cuDNN version: {cudnn_version}")
- except RuntimeError as e:
- logger.warning(
- "Encountered a cuDNN version issue. This may result in degraded performance. This issue is usually "
- "caused by an incompatible cuDNN version installed in your python environment, or on the host "
- f"system. Full error message:\n{e}"
- )
-
-
-def invoke_api() -> None:
- def find_port(port: int) -> int:
- """Find a port not in use starting at given port"""
- # Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
- # https://github.com/WaylonWalker
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
- if s.connect_ex(("localhost", port)) == 0:
- return find_port(port=port + 1)
- else:
- return port
-
- if app_config.dev_reload:
- try:
- import jurigged
- except ImportError as e:
- logger.error(
- 'Can\'t start `--dev_reload` because jurigged is not found; `pip install -e ".[dev]"` to include development dependencies.',
- exc_info=e,
- )
- else:
- jurigged.watch(logger=InvokeAILogger.get_logger(name="jurigged").info)
-
- port = find_port(app_config.port)
- if port != app_config.port:
- logger.warn(f"Port {app_config.port} in use, using port {port}")
-
- check_cudnn(logger)
-
- # Start our own event loop for eventing usage
- loop = asyncio.new_event_loop()
- config = uvicorn.Config(
- app=app,
- host=app_config.host,
- port=port,
- loop="asyncio",
- log_level=app_config.log_level,
- ssl_certfile=app_config.ssl_certfile,
- ssl_keyfile=app_config.ssl_keyfile,
- )
- server = uvicorn.Server(config)
-
- # replace uvicorn's loggers with InvokeAI's for consistent appearance
- for logname in ["uvicorn.access", "uvicorn"]:
- log = InvokeAILogger.get_logger(logname)
- log.handlers.clear()
- for ch in logger.handlers:
- log.addHandler(ch)
-
- loop.run_until_complete(server.serve())
-
-
-if __name__ == "__main__":
- invoke_api()
diff --git a/invokeai/app/invocations/__init__.py b/invokeai/app/invocations/__init__.py
index cb1caa167ef..c8d64437524 100644
--- a/invokeai/app/invocations/__init__.py
+++ b/invokeai/app/invocations/__init__.py
@@ -1,28 +1,5 @@
-import shutil
-import sys
-from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
-from invokeai.app.services.config.config_default import get_config
-
-custom_nodes_path = Path(get_config().custom_nodes_path)
-custom_nodes_path.mkdir(parents=True, exist_ok=True)
-
-custom_nodes_init_path = str(custom_nodes_path / "__init__.py")
-custom_nodes_readme_path = str(custom_nodes_path / "README.md")
-
-# copy our custom nodes __init__.py to the custom nodes directory
-shutil.copy(Path(__file__).parent / "custom_nodes/init.py", custom_nodes_init_path)
-shutil.copy(Path(__file__).parent / "custom_nodes/README.md", custom_nodes_readme_path)
-
-# Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
-spec = spec_from_file_location("custom_nodes", custom_nodes_init_path)
-if spec is None or spec.loader is None:
- raise RuntimeError(f"Could not load custom nodes from {custom_nodes_init_path}")
-module = module_from_spec(spec)
-sys.modules[spec.name] = module
-spec.loader.exec_module(module)
-
# add core nodes to __all__
python_files = filter(lambda f: not f.name.startswith("_"), Path(__file__).parent.glob("*.py"))
__all__ = [f.stem for f in python_files] # type: ignore
diff --git a/invokeai/app/invocations/anima_denoise.py b/invokeai/app/invocations/anima_denoise.py
new file mode 100644
index 00000000000..b301e817f9c
--- /dev/null
+++ b/invokeai/app/invocations/anima_denoise.py
@@ -0,0 +1,736 @@
+"""Anima denoising invocation.
+
+Implements the rectified flow denoising loop for Anima models:
+- Direct prediction: denoised = input - output * sigma
+- Fixed shift=3.0 via loglinear_timestep_shift (Flux paper by Black Forest Labs)
+- Timestep convention: timestep = sigma * 1.0 (raw sigma, NOT 1-sigma like Z-Image)
+- NO v-prediction negation (unlike Z-Image)
+- 3D latent space: [B, C, T, H, W] with T=1 for images
+- 16 latent channels, 8x spatial compression
+
+Key differences from Z-Image denoise:
+- Anima uses fixed shift=3.0, Z-Image uses dynamic shift based on resolution
+- Anima: timestep = sigma (raw), Z-Image: model_t = 1.0 - sigma
+- Anima: noise_pred = model_output (direct), Z-Image: noise_pred = -model_output (v-pred)
+- Anima transformer takes (x, timesteps, context, t5xxl_ids, t5xxl_weights)
+- Anima uses 3D latents directly, Z-Image converts 4D -> list of 5D
+"""
+
+import math
+from contextlib import ExitStack
+from typing import Callable, Iterator, Optional, Tuple
+
+import torch
+import torchvision.transforms as tv_transforms
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ AnimaConditioningField,
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+)
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import TransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.anima.anima_transformer_patch import patch_anima_for_regional_prompting
+from invokeai.backend.anima.conditioning_data import AnimaRegionalTextConditioning, AnimaTextConditioning
+from invokeai.backend.anima.regional_prompting import AnimaRegionalPromptingExtension
+from invokeai.backend.anima.scheduler_driver import AnimaSchedulerDriver
+from invokeai.backend.flux.schedulers import (
+ ANIMA_SCHEDULER_LABELS,
+ ANIMA_SCHEDULER_NAME_VALUES,
+ ANIMA_SHIFT,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.anima_lora_constants import ANIMA_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import (
+ RectifiedFlowInpaintExtension,
+ assert_broadcastable,
+)
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import AnimaConditioningInfo, Range
+from invokeai.backend.util.devices import TorchDevice
+
+# Anima uses 8x spatial compression (VAE downsamples by 2^3)
+ANIMA_LATENT_SCALE_FACTOR = 8
+# Anima uses 16 latent channels
+ANIMA_LATENT_CHANNELS = 16
+# Anima uses raw sigma values as timesteps (no rescaling)
+ANIMA_MULTIPLIER = 1.0
+
+
+def loglinear_timestep_shift(alpha: float, t: float) -> float:
+ """Apply log-linear timestep shift to a noise schedule value.
+
+ This shift biases the noise schedule toward higher noise levels, as described
+ in the Flux model (Black Forest Labs, 2024). With alpha > 1, the model spends
+ proportionally more denoising steps at higher noise levels.
+
+ Formula: sigma = alpha * t / (1 + (alpha - 1) * t)
+
+ Args:
+ alpha: Shift factor (3.0 for Anima, resolution-dependent for Flux).
+ t: Timestep value in [0, 1].
+
+ Returns:
+ Shifted timestep value.
+ """
+ if alpha == 1.0:
+ return t
+ return alpha * t / (1 + (alpha - 1) * t)
+
+
+def inverse_loglinear_timestep_shift(alpha: float, sigma: float) -> float:
+ """Recover linear t from a shifted sigma value.
+
+ Inverse of loglinear_timestep_shift: given sigma = alpha * t / (1 + (alpha-1) * t),
+ solve for t = sigma / (alpha - (alpha-1) * sigma).
+
+ This is needed for the inpainting extension, which expects linear t values
+ for gradient mask thresholding. With Anima's shift=3.0, the difference
+ between shifted sigma and linear t is large (e.g. at t=0.5, sigma=0.75),
+ causing overly aggressive mask thresholding if sigma is used directly.
+
+ Args:
+ alpha: Shift factor (3.0 for Anima).
+ sigma: Shifted sigma value in [0, 1].
+
+ Returns:
+ Linear t value in [0, 1].
+ """
+ if alpha == 1.0:
+ return sigma
+ denominator = alpha - (alpha - 1) * sigma
+ if abs(denominator) < 1e-8:
+ return 1.0
+ return sigma / denominator
+
+
+class AnimaInpaintExtension(RectifiedFlowInpaintExtension):
+ """Inpaint extension for Anima that accounts for the time-SNR shift.
+
+ Anima uses a fixed shift=3.0 which makes sigma values significantly larger
+ than the corresponding linear t values. The base RectifiedFlowInpaintExtension
+ uses t_prev for both gradient mask thresholding and noise mixing, which assumes
+ linear t values.
+
+ This subclass:
+ - Uses the LINEAR t for gradient mask thresholding (correct progressive reveal)
+ - Uses the SHIFTED sigma for noise mixing (matches the denoiser's noise level)
+ """
+
+ def __init__(
+ self,
+ init_latents: torch.Tensor,
+ inpaint_mask: torch.Tensor,
+ noise: torch.Tensor,
+ shift: float = ANIMA_SHIFT,
+ ):
+ assert_broadcastable(init_latents.shape, inpaint_mask.shape, noise.shape)
+ self._init_latents = init_latents
+ self._inpaint_mask = inpaint_mask
+ self._noise = noise
+ self._shift = shift
+
+ def merge_intermediate_latents_with_init_latents(
+ self, intermediate_latents: torch.Tensor, sigma_prev: float
+ ) -> torch.Tensor:
+ """Merge intermediate latents with init latents, correcting for Anima's shift.
+
+ Args:
+ intermediate_latents: The denoised latents at the current step.
+ sigma_prev: The SHIFTED sigma value for the next step.
+ """
+ # Recover linear t from shifted sigma for gradient mask thresholding.
+ # This ensures the gradient mask is revealed at the correct pace.
+ t_prev = inverse_loglinear_timestep_shift(self._shift, sigma_prev)
+ mask = self._apply_mask_gradient_adjustment(t_prev)
+
+ # Use shifted sigma for noise mixing to match the denoiser's noise level.
+ # The Euler step produces latents at noise level sigma_prev, so the
+ # preserved regions must also be at sigma_prev noise level.
+ noised_init_latents = self._noise * sigma_prev + (1.0 - sigma_prev) * self._init_latents
+
+ return intermediate_latents * mask + noised_init_latents * (1.0 - mask)
+
+
+@invocation(
+ "anima_denoise",
+ title="Denoise - Anima",
+ tags=["image", "anima"],
+ category="image",
+ version="1.6.0",
+ classification=Classification.Prototype,
+)
+class AnimaDenoiseInvocation(BaseInvocation):
+ """Run the denoising process with an Anima model.
+
+ Uses rectified flow sampling with shift=3.0 and the Cosmos Predict2 DiT
+ backbone with integrated LLM Adapter for text conditioning.
+
+ Supports txt2img, img2img (via latents input), and inpainting (via denoise_mask).
+ """
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.latents, input=Input.Connection
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.noise, input=Input.Connection
+ )
+ # denoise_mask is used for inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
+ )
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ add_noise: bool = InputField(default=True, description="Add noise based on denoising start.")
+ transformer: TransformerField = InputField(
+ description="Anima transformer model.", input=Input.Connection, title="Transformer"
+ )
+ positive_conditioning: AnimaConditioningField | list[AnimaConditioningField] = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: AnimaConditioningField | list[AnimaConditioningField] | None = InputField(
+ default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ guidance_scale: float = InputField(
+ default=4.5,
+ ge=1.0,
+ description="Guidance scale for classifier-free guidance. Recommended: 4.0-5.0 for Anima.",
+ title="Guidance Scale",
+ )
+ width: int = InputField(default=1024, multiple_of=8, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=8, description="Height of the generated image.")
+ steps: int = InputField(default=30, gt=0, description="Number of denoising steps. 30 recommended for Anima.")
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+ scheduler: ANIMA_SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description="Scheduler (sampler) for the denoising process.",
+ ui_choice_labels=ANIMA_SCHEDULER_LABELS,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ """Prepare the inpaint mask for Anima.
+
+ Anima uses 3D latents [B, C, T, H, W] internally but the mask operates
+ on the spatial dimensions [B, C, H, W] which match the squeezed output.
+ """
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+
+ # Invert mask: 0.0 = regions to denoise, 1.0 = regions to preserve
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask
+
+ def _get_noise(
+ self,
+ height: int,
+ width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ seed: int,
+ ) -> torch.Tensor:
+ """Generate initial noise tensor in 3D latent space [B, C, T, H, W]."""
+ rand_device = "cpu"
+ return torch.randn(
+ 1,
+ ANIMA_LATENT_CHANNELS,
+ 1, # T=1 for single image
+ height // ANIMA_LATENT_SCALE_FACTOR,
+ width // ANIMA_LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=torch.float32,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+ def _get_sigmas(self, num_steps: int) -> list[float]:
+ """Generate sigma schedule with fixed shift=3.0.
+
+ Uses the log-linear timestep shift from the Flux model (Black Forest Labs)
+ with a fixed shift factor of 3.0 (no dynamic resolution-based shift).
+
+ Returns:
+ List of num_steps + 1 sigma values from ~1.0 (noise) to 0.0 (clean).
+ """
+ sigmas = []
+ for i in range(num_steps + 1):
+ t = 1.0 - i / num_steps
+ sigma = loglinear_timestep_shift(ANIMA_SHIFT, t)
+ sigmas.append(sigma)
+ return sigmas
+
+ def _load_conditioning(
+ self,
+ context: InvocationContext,
+ cond_field: AnimaConditioningField,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> AnimaConditioningInfo:
+ """Load Anima conditioning data from storage."""
+ cond_data = context.conditioning.load(cond_field.conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ cond_info = cond_data.conditionings[0]
+ assert isinstance(cond_info, AnimaConditioningInfo)
+ return cond_info.to(dtype=dtype, device=device)
+
+ def _load_text_conditionings(
+ self,
+ context: InvocationContext,
+ cond_field: AnimaConditioningField | list[AnimaConditioningField],
+ img_token_height: int,
+ img_token_width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> list[AnimaTextConditioning]:
+ """Load Anima text conditioning with optional regional masks.
+
+ Args:
+ context: The invocation context.
+ cond_field: Single conditioning field or list of fields.
+ img_token_height: Height of the image token grid (H // patch_size).
+ img_token_width: Width of the image token grid (W // patch_size).
+ dtype: Target dtype.
+ device: Target device.
+
+ Returns:
+ List of AnimaTextConditioning objects with optional masks.
+ """
+ cond_list = cond_field if isinstance(cond_field, list) else [cond_field]
+
+ text_conditionings: list[AnimaTextConditioning] = []
+ for cond in cond_list:
+ cond_info = self._load_conditioning(context, cond, dtype, device)
+
+ # Load the mask, if provided
+ mask: torch.Tensor | None = None
+ if cond.mask is not None:
+ mask = context.tensors.load(cond.mask.tensor_name)
+ mask = mask.to(device=device)
+ mask = AnimaRegionalPromptingExtension.preprocess_regional_prompt_mask(
+ mask, img_token_height, img_token_width, dtype, device
+ )
+
+ text_conditionings.append(
+ AnimaTextConditioning(
+ qwen3_embeds=cond_info.qwen3_embeds,
+ t5xxl_ids=cond_info.t5xxl_ids,
+ t5xxl_weights=cond_info.t5xxl_weights,
+ mask=mask,
+ )
+ )
+
+ return text_conditionings
+
+ def _run_llm_adapter_for_regions(
+ self,
+ transformer,
+ text_conditionings: list[AnimaTextConditioning],
+ dtype: torch.dtype,
+ ) -> AnimaRegionalTextConditioning:
+ """Run the LLM Adapter separately for each regional conditioning and concatenate.
+
+ Args:
+ transformer: The AnimaTransformer instance (must be on device).
+ text_conditionings: List of per-region conditioning data.
+ dtype: Inference dtype.
+
+ Returns:
+ AnimaRegionalTextConditioning with concatenated context and masks.
+ """
+ context_embeds_list: list[torch.Tensor] = []
+ context_ranges: list[Range] = []
+ image_masks: list[torch.Tensor | None] = []
+ cur_len = 0
+
+ for tc in text_conditionings:
+ qwen3_embeds = tc.qwen3_embeds.unsqueeze(0) # (1, seq_len, 1024)
+ t5xxl_ids = tc.t5xxl_ids.unsqueeze(0) # (1, seq_len)
+ t5xxl_weights = None
+ if tc.t5xxl_weights is not None:
+ t5xxl_weights = tc.t5xxl_weights.unsqueeze(0).unsqueeze(-1) # (1, seq_len, 1)
+
+ # Run the LLM Adapter to produce context for this region
+ context = transformer.preprocess_text_embeds(
+ qwen3_embeds.to(dtype=dtype),
+ t5xxl_ids,
+ t5xxl_weights=t5xxl_weights.to(dtype=dtype) if t5xxl_weights is not None else None,
+ )
+ # context shape: (1, 512, 1024) — squeeze batch dim
+ context_2d = context.squeeze(0) # (512, 1024)
+
+ context_embeds_list.append(context_2d)
+ context_ranges.append(Range(start=cur_len, end=cur_len + context_2d.shape[0]))
+ image_masks.append(tc.mask)
+ cur_len += context_2d.shape[0]
+
+ concatenated_context = torch.cat(context_embeds_list, dim=0)
+
+ return AnimaRegionalTextConditioning(
+ context_embeds=concatenated_context,
+ image_masks=image_masks,
+ context_ranges=context_ranges,
+ )
+
+ def _run_diffusion(self, context: InvocationContext) -> torch.Tensor:
+ device = TorchDevice.choose_torch_device()
+ inference_dtype = TorchDevice.choose_anima_inference_dtype(device)
+
+ if self.denoising_start >= self.denoising_end:
+ raise ValueError(
+ f"denoising_start ({self.denoising_start}) must be less than denoising_end ({self.denoising_end})."
+ )
+
+ transformer_info = context.models.load(self.transformer.transformer)
+
+ # Compute image token grid dimensions for regional prompting
+ # Anima: 8x VAE compression, 2x patch size → 16x total
+ patch_size = 2
+ latent_height = self.height // ANIMA_LATENT_SCALE_FACTOR
+ latent_width = self.width // ANIMA_LATENT_SCALE_FACTOR
+ img_token_height = latent_height // patch_size
+ img_token_width = latent_width // patch_size
+ img_seq_len = img_token_height * img_token_width
+
+ # Load positive conditioning with optional regional masks
+ pos_text_conditionings = self._load_text_conditionings(
+ context=context,
+ cond_field=self.positive_conditioning,
+ img_token_height=img_token_height,
+ img_token_width=img_token_width,
+ dtype=inference_dtype,
+ device=device,
+ )
+ has_regional = len(pos_text_conditionings) > 1 or any(tc.mask is not None for tc in pos_text_conditionings)
+
+ # Load negative conditioning if CFG is enabled
+ do_cfg = not math.isclose(self.guidance_scale, 1.0) and self.negative_conditioning is not None
+ neg_text_conditionings: list[AnimaTextConditioning] | None = None
+ if do_cfg:
+ assert self.negative_conditioning is not None
+ neg_text_conditionings = self._load_text_conditionings(
+ context=context,
+ cond_field=self.negative_conditioning,
+ img_token_height=img_token_height,
+ img_token_width=img_token_width,
+ dtype=inference_dtype,
+ device=device,
+ )
+
+ # Generate sigma schedule
+ sigmas = self._get_sigmas(self.steps)
+
+ # Apply denoising_start and denoising_end clipping (for img2img/inpaint)
+ if self.denoising_start > 0 or self.denoising_end < 1:
+ total_sigmas = len(sigmas)
+ start_idx = int(self.denoising_start * (total_sigmas - 1))
+ end_idx = int(self.denoising_end * (total_sigmas - 1)) + 1
+ sigmas = sigmas[start_idx:end_idx]
+
+ total_steps = len(sigmas) - 1
+
+ # Load input latents if provided (image-to-image)
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+ # Anima denoiser works in 3D: add temporal dim if needed
+ if init_latents.ndim == 4:
+ init_latents = init_latents.unsqueeze(2) # [B, C, H, W] -> [B, C, 1, H, W]
+
+ # Generate initial noise (3D latent: [B, C, T, H, W]).
+ # If noise will never be consumed, avoid validating/loading it.
+ should_ignore_noise = init_latents is not None and not self.add_noise and self.denoise_mask is None
+ noise: torch.Tensor | None
+ if should_ignore_noise:
+ noise = None
+ else:
+ noise = self._prepare_noise_tensor(context, inference_dtype, device)
+
+ # Prepare input latents
+ if init_latents is not None:
+ if self.add_noise:
+ assert noise is not None
+ # Noise the init latents using the first sigma from the clipped
+ # InvokeAI schedule.
+ #
+ # Known limitation: if the selected scheduler later starts from a
+ # different first effective sigma/timestep than sigmas[0], the
+ # img2img preblend below may not match that scheduler exactly.
+ # This is an existing pipeline limitation and affects both
+ # internally generated noise and externally supplied noise.
+ s_0 = sigmas[0]
+ latents = s_0 * noise + (1.0 - s_0) * init_latents
+ else:
+ latents = init_latents
+ else:
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ assert noise is not None
+ latents = noise
+
+ if total_steps <= 0:
+ return latents.squeeze(2)
+
+ # Prepare inpaint extension
+ inpaint_mask = self._prep_inpaint_mask(context, latents.squeeze(2))
+ inpaint_extension: AnimaInpaintExtension | None = None
+ if inpaint_mask is not None:
+ if init_latents is None:
+ raise ValueError("Initial latents are required when using an inpaint mask (image-to-image inpainting)")
+ assert noise is not None
+ inpaint_extension = AnimaInpaintExtension(
+ init_latents=init_latents.squeeze(2),
+ inpaint_mask=inpaint_mask,
+ noise=noise.squeeze(2),
+ shift=ANIMA_SHIFT,
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ # Initialize scheduler driver if not using built-in Euler.
+ use_scheduler = self.scheduler != "euler"
+ driver: AnimaSchedulerDriver | None = None
+ if use_scheduler:
+ driver = AnimaSchedulerDriver(
+ scheduler_name=self.scheduler,
+ sigmas=sigmas,
+ steps=self.steps,
+ denoising_start=self.denoising_start,
+ denoising_end=self.denoising_end,
+ device=device,
+ seed=self.seed,
+ )
+
+ with ExitStack() as exit_stack:
+ (cached_weights, transformer) = exit_stack.enter_context(transformer_info.model_on_device())
+
+ # Apply LoRA models to the transformer.
+ # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=transformer,
+ patches=self._lora_iterator(context),
+ prefix=ANIMA_LORA_TRANSFORMER_PREFIX,
+ dtype=inference_dtype,
+ cached_weights=cached_weights,
+ )
+ )
+
+ # Run LLM Adapter for each regional conditioning to produce context vectors.
+ # This must happen with the transformer on device since it uses the adapter weights.
+ if has_regional:
+ pos_regional = self._run_llm_adapter_for_regions(transformer, pos_text_conditionings, inference_dtype)
+ pos_context = pos_regional.context_embeds.unsqueeze(0) # (1, total_ctx_len, 1024)
+
+ # Build regional prompting extension with cross-attention mask
+ regional_extension = AnimaRegionalPromptingExtension.from_regional_conditioning(
+ pos_regional, img_seq_len
+ )
+
+ # For negative, concatenate all regions without masking (matches Z-Image behavior)
+ neg_context = None
+ if do_cfg and neg_text_conditionings is not None:
+ neg_regional = self._run_llm_adapter_for_regions(
+ transformer, neg_text_conditionings, inference_dtype
+ )
+ neg_context = neg_regional.context_embeds.unsqueeze(0)
+ else:
+ # Single conditioning — run LLM Adapter via normal forward path
+ tc = pos_text_conditionings[0]
+ pos_qwen3_embeds = tc.qwen3_embeds.unsqueeze(0)
+ pos_t5xxl_ids = tc.t5xxl_ids.unsqueeze(0)
+ pos_t5xxl_weights = None
+ if tc.t5xxl_weights is not None:
+ pos_t5xxl_weights = tc.t5xxl_weights.unsqueeze(0).unsqueeze(-1)
+
+ # Pre-compute context via LLM Adapter
+ pos_context = transformer.preprocess_text_embeds(
+ pos_qwen3_embeds.to(dtype=inference_dtype),
+ pos_t5xxl_ids,
+ t5xxl_weights=pos_t5xxl_weights.to(dtype=inference_dtype)
+ if pos_t5xxl_weights is not None
+ else None,
+ )
+
+ neg_context = None
+ if do_cfg and neg_text_conditionings is not None:
+ ntc = neg_text_conditionings[0]
+ neg_qwen3 = ntc.qwen3_embeds.unsqueeze(0)
+ neg_ids = ntc.t5xxl_ids.unsqueeze(0)
+ neg_weights = None
+ if ntc.t5xxl_weights is not None:
+ neg_weights = ntc.t5xxl_weights.unsqueeze(0).unsqueeze(-1)
+ neg_context = transformer.preprocess_text_embeds(
+ neg_qwen3.to(dtype=inference_dtype),
+ neg_ids,
+ t5xxl_weights=neg_weights.to(dtype=inference_dtype) if neg_weights is not None else None,
+ )
+
+ regional_extension = None
+
+ # Apply regional prompting patch if we have regional masks
+ exit_stack.enter_context(patch_anima_for_regional_prompting(transformer, regional_extension))
+
+ # Helper to run transformer with pre-computed context (bypasses LLM Adapter)
+ def _run_transformer(ctx: torch.Tensor, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
+ return transformer(
+ x=x.to(transformer.dtype if hasattr(transformer, "dtype") else inference_dtype),
+ timesteps=t,
+ context=ctx,
+ # t5xxl_ids=None skips the LLM Adapter — context is already pre-computed
+ )
+
+ if driver is not None:
+ user_step = 0
+ pbar = tqdm(total=total_steps, desc=f"Denoising (Anima){TorchDevice.get_session_device_label()}")
+ for it in driver.iterations():
+ timestep = torch.tensor(
+ [it.sigma_curr * ANIMA_MULTIPLIER], device=device, dtype=inference_dtype
+ ).expand(latents.shape[0])
+
+ noise_pred_cond = _run_transformer(pos_context, latents, timestep).float()
+
+ if do_cfg and neg_context is not None:
+ noise_pred_uncond = _run_transformer(neg_context, latents, timestep).float()
+ noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ latents_preview = self._estimate_preview_latents(
+ latents=latents,
+ sigma=it.sigma_curr,
+ noise_pred=noise_pred,
+ )
+
+ latents = driver.step(model_output=noise_pred, timestep=it.sched_timestep, sample=latents)
+
+ if it.completes_user_step:
+ # RectifiedFlowInpaintExtension expects this once per user step (its
+ # docstring), so for Heun we skip the FO half of each pair to avoid
+ # corrupting the second-order corrector's input.
+ if inpaint_extension is not None:
+ latents_4d = latents.squeeze(2)
+ latents_4d = inpaint_extension.merge_intermediate_latents_with_init_latents(
+ latents_4d, it.sigma_prev
+ )
+ latents = latents_4d.unsqueeze(2)
+
+ user_step += 1
+ pbar.update(1)
+ step_callback(
+ PipelineIntermediateState(
+ step=user_step,
+ order=it.order,
+ total_steps=total_steps,
+ timestep=int(it.sigma_curr * 1000),
+ latents=latents_preview.squeeze(2),
+ )
+ )
+ pbar.close()
+ else:
+ # Built-in Euler implementation (default for Anima)
+ for step_idx in tqdm(
+ range(total_steps), desc=f"Denoising (Anima){TorchDevice.get_session_device_label()}"
+ ):
+ sigma_curr = sigmas[step_idx]
+ sigma_prev = sigmas[step_idx + 1]
+
+ timestep = torch.tensor(
+ [sigma_curr * ANIMA_MULTIPLIER], device=device, dtype=inference_dtype
+ ).expand(latents.shape[0])
+
+ noise_pred_cond = _run_transformer(pos_context, latents, timestep).float()
+
+ if do_cfg and neg_context is not None:
+ noise_pred_uncond = _run_transformer(neg_context, latents, timestep).float()
+ noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ latents_dtype = latents.dtype
+ latents = latents.to(dtype=torch.float32)
+ latents = latents + (sigma_prev - sigma_curr) * noise_pred
+ latents = latents.to(dtype=latents_dtype)
+ latents_preview = self._estimate_preview_latents(
+ latents=latents, sigma=sigma_prev, noise_pred=noise_pred
+ )
+
+ if inpaint_extension is not None:
+ latents_4d = latents.squeeze(2)
+ latents_4d = inpaint_extension.merge_intermediate_latents_with_init_latents(
+ latents_4d, sigma_prev
+ )
+ latents = latents_4d.unsqueeze(2)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(sigma_curr * 1000),
+ latents=latents_preview.squeeze(2),
+ ),
+ )
+
+ # Remove temporal dimension for output: [B, C, 1, H, W] -> [B, C, H, W]
+ return latents.squeeze(2)
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "Anima", self.width, self.height)
+ return noise
+
+ return self._get_noise(self.height, self.width, inference_dtype, device, self.seed)
+
+ def _estimate_preview_latents(self, latents: torch.Tensor, sigma: float, noise_pred: torch.Tensor) -> torch.Tensor:
+ latents_dtype = latents.dtype
+ latents_fp32 = latents.to(dtype=torch.float32)
+ preview = latents_fp32 - sigma * noise_pred.to(dtype=torch.float32)
+ return preview.to(dtype=latents_dtype)
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.Anima)
+
+ return step_callback
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the transformer."""
+ for lora in self.transformer.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}. "
+ "The LoRA model may be corrupted or incompatible."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/anima_image_to_latents.py b/invokeai/app/invocations/anima_image_to_latents.py
new file mode 100644
index 00000000000..83073ab4a80
--- /dev/null
+++ b/invokeai/app/invocations/anima_image_to_latents.py
@@ -0,0 +1,119 @@
+"""Anima image-to-latents invocation.
+
+Encodes an image to latent space using the Anima VAE (AutoencoderKLWan or FLUX VAE).
+
+For Wan VAE (AutoencoderKLWan):
+- Input image is converted to 5D tensor [B, C, T, H, W] with T=1
+- After encoding, latents are normalized: (latents - mean) / std
+ (inverse of the denormalization in anima_latents_to_image.py)
+
+For FLUX VAE (AutoEncoder):
+- Encoding is handled internally by the FLUX VAE
+"""
+
+from typing import Union
+
+import einops
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+AnimaVAE = Union[AutoencoderKLWan, FluxAutoEncoder]
+
+
+@invocation(
+ "anima_i2l",
+ title="Image to Latents - Anima",
+ tags=["image", "latents", "vae", "i2l", "anima"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class AnimaImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates latents from an image using the Anima VAE (supports Wan 2.1 and FLUX VAE)."""
+
+ image: ImageField = InputField(description="The image to encode.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ if not isinstance(vae_info.model, (AutoencoderKLWan, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKLWan or FluxAutoEncoder for Anima VAE, got {type(vae_info.model).__name__}."
+ )
+
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="encode",
+ image_tensor=image_tensor,
+ vae=vae_info.model,
+ )
+
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ if not isinstance(vae, (AutoencoderKLWan, FluxAutoEncoder)):
+ raise TypeError(f"Expected AutoencoderKLWan or FluxAutoEncoder, got {type(vae).__name__}.")
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ with torch.inference_mode():
+ if isinstance(vae, FluxAutoEncoder):
+ # FLUX VAE handles scaling internally
+ generator = torch.Generator(device=TorchDevice.choose_torch_device()).manual_seed(0)
+ latents = vae.encode(image_tensor, sample=True, generator=generator)
+ else:
+ # AutoencoderKLWan expects 5D input [B, C, T, H, W]
+ if image_tensor.ndim == 4:
+ image_tensor = image_tensor.unsqueeze(2) # [B, C, H, W] -> [B, C, 1, H, W]
+
+ encoded = vae.encode(image_tensor, return_dict=False)[0]
+ latents = encoded.sample().to(dtype=vae_dtype)
+
+ # Normalize to denoiser space: (latents - mean) / std
+ # This is the inverse of the denormalization in anima_latents_to_image.py
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
+ latents = (latents - latents_mean) / latents_std
+
+ # Remove temporal dimension: [B, C, 1, H, W] -> [B, C, H, W]
+ if latents.ndim == 5:
+ latents = latents.squeeze(2)
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, (AutoencoderKLWan, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKLWan or FluxAutoEncoder for Anima VAE, got {type(vae_info.model).__name__}."
+ )
+
+ context.util.signal_progress("Running Anima VAE encode")
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/anima_latents_to_image.py b/invokeai/app/invocations/anima_latents_to_image.py
new file mode 100644
index 00000000000..080c101fa44
--- /dev/null
+++ b/invokeai/app/invocations/anima_latents_to_image.py
@@ -0,0 +1,108 @@
+"""Anima latents-to-image invocation.
+
+Decodes Anima latents using the QwenImage VAE (AutoencoderKLWan) or
+compatible FLUX VAE as fallback.
+
+Latents from the denoiser are in normalized space (zero-centered). Before
+VAE decode, they must be denormalized using the Wan 2.1 per-channel
+mean/std: latents = latents * std + mean (matching diffusers WanPipeline).
+
+The VAE expects 5D latents [B, C, T, H, W] — for single images, T=1.
+"""
+
+import torch
+from diffusers.models.autoencoders import AutoencoderKLWan
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+
+@invocation(
+ "anima_l2i",
+ title="Latents to Image - Anima",
+ tags=["latents", "image", "vae", "l2i", "anima"],
+ category="latents",
+ version="1.0.2",
+ classification=Classification.Prototype,
+)
+class AnimaLatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents using the Anima VAE.
+
+ Supports the Wan 2.1 QwenImage VAE (AutoencoderKLWan) with explicit
+ latent denormalization, and FLUX VAE as fallback.
+ """
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, (AutoencoderKLWan, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKLWan or FluxAutoEncoder for Anima VAE, got {type(vae_info.model).__name__}."
+ )
+
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="decode",
+ image_tensor=latents,
+ vae=vae_info.model,
+ )
+
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ context.util.signal_progress("Running Anima VAE decode")
+ if not isinstance(vae, (AutoencoderKLWan, FluxAutoEncoder)):
+ raise TypeError(f"Expected AutoencoderKLWan or FluxAutoEncoder, got {type(vae).__name__}.")
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode():
+ if isinstance(vae, FluxAutoEncoder):
+ # FLUX VAE handles scaling internally, expects 4D [B, C, H, W]
+ img = vae.decode(latents)
+ else:
+ # Expects 5D latents [B, C, T, H, W]
+ if latents.ndim == 4:
+ latents = latents.unsqueeze(2) # [B, C, H, W] -> [B, C, 1, H, W]
+
+ # Denormalize from denoiser space to raw VAE space
+ # (same as diffusers WanPipeline and ComfyUI Wan21.process_out)
+ latents_mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents)
+ latents_std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents)
+ latents = latents * latents_std + latents_mean
+
+ decoded = vae.decode(latents, return_dict=False)[0]
+
+ # Output is 5D [B, C, T, H, W] — squeeze temporal dim
+ if decoded.ndim == 5:
+ decoded = decoded.squeeze(2)
+ img = decoded
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c")
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/anima_lora_loader.py b/invokeai/app/invocations/anima_lora_loader.py
new file mode 100644
index 00000000000..6a035b55aa6
--- /dev/null
+++ b/invokeai/app/invocations/anima_lora_loader.py
@@ -0,0 +1,162 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, Qwen3EncoderField, TransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("anima_lora_loader_output")
+class AnimaLoRALoaderOutput(BaseInvocationOutput):
+ """Anima LoRA Loader Output"""
+
+ transformer: Optional[TransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="Anima Transformer"
+ )
+ qwen3_encoder: Optional[Qwen3EncoderField] = OutputField(
+ default=None, description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder"
+ )
+
+
+@invocation(
+ "anima_lora_loader",
+ title="Apply LoRA - Anima",
+ tags=["lora", "model", "anima"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class AnimaLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA model to an Anima transformer and/or Qwen3 text encoder."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.Anima,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
+ transformer: TransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Anima Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> AnimaLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ if self.transformer and any(lora.lora.key == lora_key for lora in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to transformer.')
+ if self.qwen3_encoder and any(lora.lora.key == lora_key for lora in self.qwen3_encoder.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to Qwen3 encoder.')
+
+ output = AnimaLoRALoaderOutput()
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ output.transformer.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+ output.qwen3_encoder.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "anima_lora_collection_loader",
+ title="Apply LoRA Collection - Anima",
+ tags=["lora", "model", "anima"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class AnimaLoRACollectionLoader(BaseInvocation):
+ """Applies a collection of LoRAs to an Anima transformer."""
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+
+ transformer: Optional[TransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> AnimaLoRALoaderOutput:
+ output = AnimaLoRALoaderOutput()
+
+ if self.loras is None:
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+ return output
+
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added_loras: list[str] = []
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+
+ for lora in loras:
+ if lora is None:
+ continue
+ if lora.lora.key in added_loras:
+ continue
+
+ if not context.models.exists(lora.lora.key):
+ raise ValueError(f"Unknown lora: {lora.lora.key}!")
+
+ if lora.lora.base is not BaseModelType.Anima:
+ raise ValueError(
+ f"LoRA '{lora.lora.key}' is for {lora.lora.base.value if lora.lora.base else 'unknown'} models, "
+ "not Anima models. Ensure you are using an Anima compatible LoRA."
+ )
+
+ added_loras.append(lora.lora.key)
+
+ if self.transformer is not None and output.transformer is not None:
+ output.transformer.loras.append(lora)
+
+ if self.qwen3_encoder is not None and output.qwen3_encoder is not None:
+ output.qwen3_encoder.loras.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/anima_model_loader.py b/invokeai/app/invocations/anima_model_loader.py
new file mode 100644
index 00000000000..0841f58bd9c
--- /dev/null
+++ b/invokeai/app/invocations/anima_model_loader.py
@@ -0,0 +1,86 @@
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ ModelIdentifierField,
+ Qwen3EncoderField,
+ TransformerField,
+ VAEField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
+
+
+@invocation_output("anima_model_loader_output")
+class AnimaModelLoaderOutput(BaseInvocationOutput):
+ """Anima model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ qwen3_encoder: Qwen3EncoderField = OutputField(description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "anima_model_loader",
+ title="Main Model - Anima",
+ tags=["model", "anima"],
+ category="model",
+ version="1.4.0",
+ classification=Classification.Prototype,
+)
+class AnimaModelLoaderInvocation(BaseInvocation):
+ """Loads an Anima model, outputting its submodels.
+
+ Anima uses:
+ - Transformer: Cosmos Predict2 DiT + LLM Adapter (from single-file checkpoint)
+ - Qwen3 Encoder: Qwen3 0.6B (standalone single-file)
+ - VAE: AutoencoderKLQwenImage / Wan 2.1 VAE (standalone single-file or FLUX VAE)
+
+ The T5-XXL tokenizer needed for LLM Adapter token IDs is bundled in the package,
+ so no T5-XXL encoder model needs to be installed.
+ """
+
+ model: ModelIdentifierField = InputField(
+ description="Anima main model (transformer + LLM adapter).",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Anima,
+ ui_model_type=ModelType.Main,
+ title="Transformer",
+ )
+
+ vae_model: ModelIdentifierField = InputField(
+ description="Standalone VAE model. Anima uses a Wan 2.1 / QwenImage VAE (16-channel). "
+ "A FLUX VAE can also be used as a compatible fallback.",
+ input=Input.Direct,
+ ui_model_type=ModelType.VAE,
+ title="VAE",
+ )
+
+ qwen3_encoder_model: ModelIdentifierField = InputField(
+ description="Standalone Qwen3 0.6B Encoder model.",
+ input=Input.Direct,
+ ui_model_type=ModelType.Qwen3Encoder,
+ title="Qwen3 Encoder",
+ )
+
+ def invoke(self, context: InvocationContext) -> AnimaModelLoaderOutput:
+ # Transformer always comes from the main model
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+
+ # VAE
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+
+ # Qwen3 Encoder
+ qwen3_tokenizer = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+
+ return AnimaModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ qwen3_encoder=Qwen3EncoderField(tokenizer=qwen3_tokenizer, text_encoder=qwen3_encoder),
+ vae=VAEField(vae=vae),
+ )
diff --git a/invokeai/app/invocations/anima_text_encoder.py b/invokeai/app/invocations/anima_text_encoder.py
new file mode 100644
index 00000000000..c9bad65f3d0
--- /dev/null
+++ b/invokeai/app/invocations/anima_text_encoder.py
@@ -0,0 +1,217 @@
+"""Anima text encoder invocation.
+
+Encodes text using the dual-conditioning pipeline:
+1. Qwen3 0.6B: Produces hidden states (last layer)
+2. T5-XXL Tokenizer: Produces token IDs only (no T5 model needed)
+
+Both outputs are stored together in AnimaConditioningInfo and used by
+the LLM Adapter inside the transformer during denoising.
+
+Key differences from Z-Image text encoder:
+- Anima uses Qwen3 0.6B (base model, NOT instruct) — no chat template
+- Anima additionally tokenizes with T5-XXL tokenizer to get token IDs
+- Qwen3 output uses all positions (including padding) for full context
+"""
+
+from contextlib import ExitStack
+from typing import Iterator, Tuple
+
+import torch
+from transformers import PreTrainedModel, PreTrainedTokenizerBase
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ AnimaConditioningField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ TensorField,
+ UIComponent,
+)
+from invokeai.app.invocations.model import Qwen3EncoderField
+from invokeai.app.invocations.primitives import AnimaConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.anima.t5_tokenizer import load_bundled_t5_tokenizer
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.anima_lora_constants import ANIMA_LORA_QWEN3_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ AnimaConditioningInfo,
+ ConditioningFieldData,
+)
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.logging import InvokeAILogger
+
+logger = InvokeAILogger.get_logger(__name__)
+
+# T5-XXL max sequence length for token IDs
+T5_MAX_SEQ_LEN = 512
+
+# Safety cap for Qwen3 sequence length to prevent GPU OOM on extremely long prompts.
+# Qwen3 0.6B supports 32K context but the LLM Adapter doesn't need that much.
+QWEN3_MAX_SEQ_LEN = 8192
+
+
+@invocation(
+ "anima_text_encoder",
+ title="Prompt - Anima",
+ tags=["prompt", "conditioning", "anima"],
+ category="conditioning",
+ version="1.4.0",
+ classification=Classification.Prototype,
+ idle_gpu_offloadable=True,
+)
+class AnimaTextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for an Anima image.
+
+ Uses Qwen3 0.6B for hidden state extraction and a bundled T5-XXL tokenizer for
+ token IDs (no T5 model weights needed). Both are combined by the
+ LLM Adapter inside the Anima transformer during denoising.
+ """
+
+ prompt: str = InputField(description="Text prompt to encode.", ui_component=UIComponent.Textarea)
+ qwen3_encoder: Qwen3EncoderField = InputField(
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+ mask: TensorField | None = InputField(
+ default=None,
+ description="A mask defining the region that this conditioning prompt applies to.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> AnimaConditioningOutput:
+ qwen3_embeds, t5xxl_ids, t5xxl_weights = self._encode_prompt(context)
+
+ # Move to CPU for storage
+ qwen3_embeds = qwen3_embeds.detach().to("cpu")
+ t5xxl_ids = t5xxl_ids.detach().to("cpu")
+ t5xxl_weights = t5xxl_weights.detach().to("cpu") if t5xxl_weights is not None else None
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[
+ AnimaConditioningInfo(
+ qwen3_embeds=qwen3_embeds,
+ t5xxl_ids=t5xxl_ids,
+ t5xxl_weights=t5xxl_weights,
+ )
+ ]
+ )
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return AnimaConditioningOutput(
+ conditioning=AnimaConditioningField(conditioning_name=conditioning_name, mask=self.mask)
+ )
+
+ def _encode_prompt(
+ self,
+ context: InvocationContext,
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]:
+ """Encode prompt using Qwen3 0.6B and T5-XXL tokenizer.
+
+ Returns:
+ Tuple of (qwen3_embeds, t5xxl_ids, t5xxl_weights).
+ - qwen3_embeds: Shape (max_seq_len, 1024) — includes all positions (including padding)
+ to preserve full sequence context for the LLM Adapter.
+ - t5xxl_ids: Shape (seq_len,) — T5-XXL token IDs (unpadded).
+ - t5xxl_weights: None (uniform weights for now).
+ """
+ prompt = self.prompt
+
+ # --- Step 1: Encode with Qwen3 0.6B ---
+ text_encoder_info = context.models.load(self.qwen3_encoder.text_encoder)
+ tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
+
+ with ExitStack() as exit_stack:
+ (_, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
+ (_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
+
+ device = text_encoder.device
+
+ # Apply LoRA models to the text encoder
+ lora_dtype = TorchDevice.choose_anima_inference_dtype(device)
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=text_encoder,
+ patches=self._lora_iterator(context),
+ prefix=ANIMA_LORA_QWEN3_PREFIX,
+ dtype=lora_dtype,
+ )
+ )
+
+ if not isinstance(text_encoder, PreTrainedModel):
+ raise TypeError(f"Expected PreTrainedModel for text encoder, got {type(text_encoder).__name__}.")
+ if not isinstance(tokenizer, PreTrainedTokenizerBase):
+ raise TypeError(f"Expected PreTrainedTokenizerBase for tokenizer, got {type(tokenizer).__name__}.")
+
+ context.util.signal_progress("Running Qwen3 0.6B text encoder")
+
+ # Anima uses base Qwen3 (not instruct) — tokenize directly, no chat template.
+ # A safety cap is applied to prevent GPU OOM on extremely long prompts.
+ text_inputs = tokenizer(
+ prompt,
+ padding=False,
+ truncation=True,
+ max_length=QWEN3_MAX_SEQ_LEN,
+ return_attention_mask=True,
+ return_tensors="pt",
+ )
+
+ text_input_ids = text_inputs.input_ids
+ attention_mask = text_inputs.attention_mask
+ if not isinstance(text_input_ids, torch.Tensor) or not isinstance(attention_mask, torch.Tensor):
+ raise TypeError("Tokenizer returned unexpected types.")
+
+ if text_input_ids.shape[-1] == QWEN3_MAX_SEQ_LEN:
+ logger.warning(
+ f"Prompt was truncated to {QWEN3_MAX_SEQ_LEN} tokens. "
+ "Consider shortening the prompt for best results."
+ )
+
+ # Ensure at least 1 token (empty prompts produce 0 tokens with padding=False)
+ if text_input_ids.shape[-1] == 0:
+ pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
+ text_input_ids = torch.tensor([[pad_id]])
+ attention_mask = torch.tensor([[1]])
+
+ # Get last hidden state from Qwen3 (final layer output)
+ prompt_mask = attention_mask.to(device).bool()
+ outputs = text_encoder(
+ text_input_ids.to(device),
+ attention_mask=prompt_mask,
+ output_hidden_states=True,
+ )
+
+ if not hasattr(outputs, "hidden_states") or outputs.hidden_states is None:
+ raise RuntimeError("Text encoder did not return hidden_states.")
+ if len(outputs.hidden_states) < 1:
+ raise RuntimeError(f"Expected at least 1 hidden state, got {len(outputs.hidden_states)}.")
+
+ # Use last hidden state — only real tokens, no padding
+ qwen3_embeds = outputs.hidden_states[-1][0] # Shape: (seq_len, 1024)
+
+ # --- Step 2: Tokenize with bundled T5-XXL tokenizer (IDs only, no model) ---
+ context.util.signal_progress("Tokenizing with T5-XXL")
+ t5_tokenizer = load_bundled_t5_tokenizer()
+ t5_tokens = t5_tokenizer(
+ prompt,
+ padding=False,
+ truncation=True,
+ max_length=T5_MAX_SEQ_LEN,
+ return_tensors="pt",
+ )
+ t5xxl_ids = t5_tokens.input_ids[0] # Shape: (seq_len,)
+
+ return qwen3_embeds, t5xxl_ids, None
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the Qwen3 text encoder."""
+ for lora in self.qwen3_encoder.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}. "
+ "The LoRA model may be corrupted or incompatible."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py
index 1d169f0a82d..95cac4065a3 100644
--- a/invokeai/app/invocations/baseinvocation.py
+++ b/invokeai/app/invocations/baseinvocation.py
@@ -4,9 +4,13 @@
import inspect
import re
+import sys
+import types
+import typing
import warnings
from abc import ABC, abstractmethod
from enum import Enum
+from functools import lru_cache
from inspect import signature
from typing import (
TYPE_CHECKING,
@@ -18,20 +22,23 @@
Literal,
Optional,
Type,
+ TypedDict,
TypeVar,
Union,
cast,
)
import semver
-from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
+from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, create_model
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
-from typing_extensions import TypeAliasType
from invokeai.app.invocations.fields import (
FieldKind,
Input,
+ InputFieldJSONSchemaExtra,
+ UIType,
+ migrate_model_ui_type,
)
from invokeai.app.services.config.config_default import get_config
from invokeai.app.services.shared.invocation_context import InvocationContext
@@ -40,12 +47,10 @@
from invokeai.backend.util.logging import InvokeAILogger
if TYPE_CHECKING:
- from ..services.invocation_services import InvocationServices
+ from invokeai.app.services.invocation_services import InvocationServices
logger = InvokeAILogger.get_logger()
-CUSTOM_NODE_PACK_SUFFIX = "__invokeai-custom-node"
-
class InvalidVersionError(ValueError):
pass
@@ -61,11 +66,28 @@ class Classification(str, Enum, metaclass=MetaEnum):
- `Stable`: The invocation, including its inputs/outputs and internal logic, is stable. You may build workflows with it, having confidence that they will not break because of a change in this invocation.
- `Beta`: The invocation is not yet stable, but is planned to be stable in the future. Workflows built around this invocation may break, but we are committed to supporting this invocation long-term.
- `Prototype`: The invocation is not yet stable and may be removed from the application at any time. Workflows built around this invocation may break, and we are *not* committed to supporting this invocation.
+ - `Deprecated`: The invocation is deprecated and may be removed in a future version.
+ - `Internal`: The invocation is not intended for use by end-users. It may be changed or removed at any time, but is exposed for users to play with.
+ - `Special`: The invocation is a special case and does not fit into any of the other classifications.
"""
Stable = "stable"
Beta = "beta"
Prototype = "prototype"
+ Deprecated = "deprecated"
+ Internal = "internal"
+ Special = "special"
+
+
+class Bottleneck(str, Enum, metaclass=MetaEnum):
+ """
+ The bottleneck of an invocation.
+ - `Network`: The invocation's execution is network-bound.
+ - `GPU`: The invocation's execution is GPU-bound.
+ """
+
+ Network = "network"
+ GPU = "gpu"
class UIConfigBase(BaseModel):
@@ -74,13 +96,13 @@ class UIConfigBase(BaseModel):
This is used internally by the @invocation decorator logic. Do not use this directly.
"""
- tags: Optional[list[str]] = Field(default_factory=None, description="The node's tags")
+ tags: Optional[list[str]] = Field(default=None, description="The node's tags")
title: Optional[str] = Field(default=None, description="The node's display name")
category: Optional[str] = Field(default=None, description="The node's category")
version: str = Field(
description='The node\'s version. Should be a valid semver string e.g. "1.0.0" or "3.8.13".',
)
- node_pack: Optional[str] = Field(default=None, description="Whether or not this is a custom node")
+ node_pack: str = Field(description="The node pack that this node belongs to, will be 'invokeai' for built-in nodes")
classification: Classification = Field(default=Classification.Stable, description="The node's classification")
model_config = ConfigDict(
@@ -89,6 +111,11 @@ class UIConfigBase(BaseModel):
)
+class OriginalModelField(TypedDict):
+ annotation: Any
+ field_info: FieldInfo
+
+
class BaseInvocationOutput(BaseModel):
"""
Base class for all invocation outputs.
@@ -96,36 +123,11 @@ class BaseInvocationOutput(BaseModel):
All invocation outputs must use the `@invocation_output` decorator to provide their unique type.
"""
- _output_classes: ClassVar[set[BaseInvocationOutput]] = set()
- _typeadapter: ClassVar[Optional[TypeAdapter[Any]]] = None
- _typeadapter_needs_update: ClassVar[bool] = False
-
- @classmethod
- def register_output(cls, output: BaseInvocationOutput) -> None:
- """Registers an invocation output."""
- cls._output_classes.add(output)
- cls._typeadapter_needs_update = True
-
- @classmethod
- def get_outputs(cls) -> Iterable[BaseInvocationOutput]:
- """Gets all invocation outputs."""
- return cls._output_classes
-
- @classmethod
- def get_typeadapter(cls) -> TypeAdapter[Any]:
- """Gets a pydantc TypeAdapter for the union of all invocation output types."""
- if not cls._typeadapter or cls._typeadapter_needs_update:
- AnyInvocationOutput = TypeAliasType(
- "AnyInvocationOutput", Annotated[Union[tuple(cls._output_classes)], Field(discriminator="type")]
- )
- cls._typeadapter = TypeAdapter(AnyInvocationOutput)
- cls._typeadapter_needs_update = False
- return cls._typeadapter
-
- @classmethod
- def get_output_types(cls) -> Iterable[str]:
- """Gets all invocation output types."""
- return (i.get_type() for i in BaseInvocationOutput.get_outputs())
+ output_meta: Optional[dict[str, JsonValue]] = Field(
+ default=None,
+ description="Optional dictionary of metadata for the invocation output, unrelated to the invocation's actual output value. This is not exposed as an output field.",
+ json_schema_extra={"field_kind": FieldKind.NodeAttribute},
+ )
@staticmethod
def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocationOutput]) -> None:
@@ -142,6 +144,9 @@ def get_type(cls) -> str:
"""Gets the invocation output's type, as provided by the `@invocation_output` decorator."""
return cls.model_fields["type"].default
+ _original_model_fields: ClassVar[dict[str, OriginalModelField]] = {}
+ """The original model fields, before any modifications were made by the @invocation_output decorator."""
+
model_config = ConfigDict(
protected_namespaces=(),
validate_assignment=True,
@@ -169,79 +174,29 @@ class BaseInvocation(ABC, BaseModel):
All invocations must use the `@invocation` decorator to provide their unique type.
"""
- _invocation_classes: ClassVar[set[BaseInvocation]] = set()
- _typeadapter: ClassVar[Optional[TypeAdapter[Any]]] = None
- _typeadapter_needs_update: ClassVar[bool] = False
-
@classmethod
def get_type(cls) -> str:
"""Gets the invocation's type, as provided by the `@invocation` decorator."""
return cls.model_fields["type"].default
@classmethod
- def register_invocation(cls, invocation: BaseInvocation) -> None:
- """Registers an invocation."""
- cls._invocation_classes.add(invocation)
- cls._typeadapter_needs_update = True
-
- @classmethod
- def get_typeadapter(cls) -> TypeAdapter[Any]:
- """Gets a pydantc TypeAdapter for the union of all invocation types."""
- if not cls._typeadapter or cls._typeadapter_needs_update:
- AnyInvocation = TypeAliasType(
- "AnyInvocation", Annotated[Union[tuple(cls._invocation_classes)], Field(discriminator="type")]
- )
- cls._typeadapter = TypeAdapter(AnyInvocation)
- cls._typeadapter_needs_update = False
- return cls._typeadapter
-
- @classmethod
- def get_invocations(cls) -> Iterable[BaseInvocation]:
- """Gets all invocations, respecting the allowlist and denylist."""
- app_config = get_config()
- allowed_invocations: set[BaseInvocation] = set()
- for sc in cls._invocation_classes:
- invocation_type = sc.get_type()
- is_in_allowlist = (
- invocation_type in app_config.allow_nodes if isinstance(app_config.allow_nodes, list) else True
- )
- is_in_denylist = (
- invocation_type in app_config.deny_nodes if isinstance(app_config.deny_nodes, list) else False
- )
- if is_in_allowlist and not is_in_denylist:
- allowed_invocations.add(sc)
- return allowed_invocations
-
- @classmethod
- def get_invocations_map(cls) -> dict[str, BaseInvocation]:
- """Gets a map of all invocation types to their invocation classes."""
- return {i.get_type(): i for i in BaseInvocation.get_invocations()}
-
- @classmethod
- def get_invocation_types(cls) -> Iterable[str]:
- """Gets all invocation types."""
- return (i.get_type() for i in BaseInvocation.get_invocations())
-
- @classmethod
- def get_output_annotation(cls) -> BaseInvocationOutput:
+ def get_output_annotation(cls) -> Type[BaseInvocationOutput]:
"""Gets the invocation's output annotation (i.e. the return annotation of its `invoke()` method)."""
return signature(cls.invoke).return_annotation
@staticmethod
def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocation]) -> None:
"""Adds various UI-facing attributes to the invocation's OpenAPI schema."""
- uiconfig = cast(UIConfigBase | None, getattr(model_class, "UIConfig", None))
- if uiconfig is not None:
- if uiconfig.title is not None:
- schema["title"] = uiconfig.title
- if uiconfig.tags is not None:
- schema["tags"] = uiconfig.tags
- if uiconfig.category is not None:
- schema["category"] = uiconfig.category
- if uiconfig.node_pack is not None:
- schema["node_pack"] = uiconfig.node_pack
- schema["classification"] = uiconfig.classification
- schema["version"] = uiconfig.version
+ if title := model_class.UIConfig.title:
+ schema["title"] = title
+ if tags := model_class.UIConfig.tags:
+ schema["tags"] = tags
+ if category := model_class.UIConfig.category:
+ schema["category"] = category
+ if node_pack := model_class.UIConfig.node_pack:
+ schema["node_pack"] = node_pack
+ schema["classification"] = model_class.UIConfig.classification
+ schema["version"] = model_class.UIConfig.version
if "required" not in schema or not isinstance(schema["required"], list):
schema["required"] = []
schema["class"] = "invocation"
@@ -257,7 +212,7 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi
Internal invoke method, calls `invoke()` after some prep.
Handles optional fields that are required to call `invoke()` and invocation cache.
"""
- for field_name, field in self.model_fields.items():
+ for field_name, field in type(self).model_fields.items():
if not field.json_schema_extra or callable(field.json_schema_extra):
# something has gone terribly awry, we should always have this and it should be a dict
continue
@@ -272,9 +227,9 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi
setattr(self, field_name, orig_default)
if orig_required and orig_default is PydanticUndefined and getattr(self, field_name) is None:
if input_ == Input.Connection:
- raise RequiredConnectionException(self.model_fields["type"].default, field_name)
+ raise RequiredConnectionException(type(self).model_fields["type"].default, field_name)
elif input_ == Input.Any:
- raise MissingInputException(self.model_fields["type"].default, field_name)
+ raise MissingInputException(type(self).model_fields["type"].default, field_name)
# skip node cache codepath if it's disabled
if services.configuration.node_cache_size == 0:
@@ -304,7 +259,9 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi
is_intermediate: bool = Field(
default=False,
description="Whether or not this is an intermediate invocation.",
- json_schema_extra={"ui_type": "IsIntermediate", "field_kind": FieldKind.NodeAttribute},
+ json_schema_extra=InputFieldJSONSchemaExtra(
+ input=Input.Direct, field_kind=FieldKind.NodeAttribute, ui_type=UIType._IsIntermediate
+ ).model_dump(exclude_none=True),
)
use_cache: bool = Field(
default=True,
@@ -312,7 +269,15 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi
json_schema_extra={"field_kind": FieldKind.NodeAttribute},
)
- UIConfig: ClassVar[Type[UIConfigBase]]
+ bottleneck: ClassVar[Bottleneck]
+
+ idle_gpu_offloadable: ClassVar[bool] = False
+ """Whether this node's entire execution may be temporarily re-pinned to an idle GPU when
+ `offload_text_encoders_to_idle_gpus` is enabled in multi-GPU mode. Only set this to True on nodes
+ that exclusively load encoder model(s), run a forward pass, and store their result on the CPU —
+ i.e. nodes that do no work tied to the session's own GPU. Set via the `@invocation` decorator."""
+
+ UIConfig: ClassVar[UIConfigBase]
model_config = ConfigDict(
protected_namespaces=(),
@@ -322,21 +287,190 @@ def invoke_internal(self, context: InvocationContext, services: "InvocationServi
coerce_numbers_to_str=True,
)
+ _original_model_fields: ClassVar[dict[str, OriginalModelField]] = {}
+ """The original model fields, before any modifications were made by the @invocation decorator."""
+
TBaseInvocation = TypeVar("TBaseInvocation", bound=BaseInvocation)
+class InvocationRegistry:
+ _invocation_classes: ClassVar[set[type[BaseInvocation]]] = set()
+ _output_classes: ClassVar[set[type[BaseInvocationOutput]]] = set()
+
+ @classmethod
+ def register_invocation(cls, invocation: type[BaseInvocation]) -> None:
+ """Registers an invocation."""
+
+ invocation_type = invocation.get_type()
+ node_pack = invocation.UIConfig.node_pack
+
+ # Log a warning when an existing invocation is being clobbered by the one we are registering
+ clobbered_invocation = InvocationRegistry.get_invocation_for_type(invocation_type)
+ if clobbered_invocation is not None:
+ # This should always be true - we just checked if the invocation type was in the set
+ clobbered_node_pack = clobbered_invocation.UIConfig.node_pack
+
+ if clobbered_node_pack == "invokeai":
+ # The invocation being clobbered is a core invocation
+ logger.warning(f'Overriding core node "{invocation_type}" with node from "{node_pack}"')
+ else:
+ # The invocation being clobbered is a custom invocation
+ logger.warning(
+ f'Overriding node "{invocation_type}" from "{node_pack}" with node from "{clobbered_node_pack}"'
+ )
+ cls._invocation_classes.remove(clobbered_invocation)
+
+ cls._invocation_classes.add(invocation)
+ cls.invalidate_invocation_typeadapter()
+
+ @classmethod
+ @lru_cache(maxsize=1)
+ def get_invocation_typeadapter(cls) -> TypeAdapter[Any]:
+ """Gets a pydantic TypeAdapter for the union of all invocation types.
+
+ This is used to parse serialized invocations into the correct invocation class.
+
+ This method is cached to avoid rebuilding the TypeAdapter on every access. If the invocation allowlist or
+ denylist is changed, the cache should be cleared to ensure the TypeAdapter is updated and validation respects
+ the updated allowlist and denylist.
+
+ @see https://docs.pydantic.dev/latest/concepts/type_adapter/
+ """
+ return TypeAdapter(Annotated[Union[tuple(cls.get_invocation_classes())], Field(discriminator="type")])
+
+ @classmethod
+ def invalidate_invocation_typeadapter(cls) -> None:
+ """Invalidates the cached invocation type adapter."""
+ cls.get_invocation_typeadapter.cache_clear()
+
+ @classmethod
+ def unregister_pack(cls, node_pack: str) -> list[str]:
+ """Unregisters all invocations and outputs belonging to a node pack.
+
+ Returns a list of the invocation types that were removed.
+ """
+ removed_types: list[str] = []
+
+ invocations_to_remove = {inv for inv in cls._invocation_classes if inv.UIConfig.node_pack == node_pack}
+ for inv in invocations_to_remove:
+ removed_types.append(inv.get_type())
+ cls._invocation_classes.discard(inv)
+
+ if invocations_to_remove:
+ cls.invalidate_invocation_typeadapter()
+
+ # Also remove any output classes from this pack's modules
+ outputs_to_remove = {out for out in cls._output_classes if out.__module__.split(".")[0] == node_pack}
+ for out in outputs_to_remove:
+ cls._output_classes.discard(out)
+
+ if outputs_to_remove:
+ cls.invalidate_output_typeadapter()
+
+ return removed_types
+
+ @classmethod
+ def get_invocation_classes(cls) -> Iterable[type[BaseInvocation]]:
+ """Gets all invocations, respecting the allowlist and denylist."""
+ app_config = get_config()
+ allowed_invocations: set[type[BaseInvocation]] = set()
+ for sc in cls._invocation_classes:
+ invocation_type = sc.get_type()
+ is_in_allowlist = (
+ invocation_type in app_config.allow_nodes if isinstance(app_config.allow_nodes, list) else True
+ )
+ is_in_denylist = (
+ invocation_type in app_config.deny_nodes if isinstance(app_config.deny_nodes, list) else False
+ )
+ if is_in_allowlist and not is_in_denylist:
+ allowed_invocations.add(sc)
+ return allowed_invocations
+
+ @classmethod
+ def get_invocations_map(cls) -> dict[str, type[BaseInvocation]]:
+ """Gets a map of all invocation types to their invocation classes."""
+ return {i.get_type(): i for i in cls.get_invocation_classes()}
+
+ @classmethod
+ def get_invocation_types(cls) -> Iterable[str]:
+ """Gets all invocation types."""
+ return (i.get_type() for i in cls.get_invocation_classes())
+
+ @classmethod
+ def get_invocation_for_type(cls, invocation_type: str) -> type[BaseInvocation] | None:
+ """Gets the invocation class for a given invocation type."""
+ return cls.get_invocations_map().get(invocation_type)
+
+ @classmethod
+ def register_output(cls, output: "type[TBaseInvocationOutput]") -> None:
+ """Registers an invocation output."""
+ output_type = output.get_type()
+
+ # Log a warning when an existing invocation is being clobbered by the one we are registering
+ clobbered_output = InvocationRegistry.get_output_for_type(output_type)
+ if clobbered_output is not None:
+ # TODO(psyche): We do not record the node pack of the output, so we cannot log it here
+ logger.warning(f'Overriding invocation output "{output_type}"')
+ cls._output_classes.remove(clobbered_output)
+
+ cls._output_classes.add(output)
+ cls.invalidate_output_typeadapter()
+
+ @classmethod
+ def get_output_classes(cls) -> Iterable[type[BaseInvocationOutput]]:
+ """Gets all invocation outputs."""
+ return cls._output_classes
+
+ @classmethod
+ def get_outputs_map(cls) -> dict[str, type[BaseInvocationOutput]]:
+ """Gets a map of all output types to their output classes."""
+ return {i.get_type(): i for i in cls.get_output_classes()}
+
+ @classmethod
+ @lru_cache(maxsize=1)
+ def get_output_typeadapter(cls) -> TypeAdapter[Any]:
+ """Gets a pydantic TypeAdapter for the union of all invocation output types.
+
+ This is used to parse serialized invocation outputs into the correct invocation output class.
+
+ This method is cached to avoid rebuilding the TypeAdapter on every access. If the invocation allowlist or
+ denylist is changed, the cache should be cleared to ensure the TypeAdapter is updated and validation respects
+ the updated allowlist and denylist.
+
+ @see https://docs.pydantic.dev/latest/concepts/type_adapter/
+ """
+ return TypeAdapter(Annotated[Union[tuple(cls._output_classes)], Field(discriminator="type")])
+
+ @classmethod
+ def invalidate_output_typeadapter(cls) -> None:
+ """Invalidates the cached invocation output type adapter."""
+ cls.get_output_typeadapter.cache_clear()
+
+ @classmethod
+ def get_output_types(cls) -> Iterable[str]:
+ """Gets all invocation output types."""
+ return (i.get_type() for i in cls.get_output_classes())
+
+ @classmethod
+ def get_output_for_type(cls, output_type: str) -> type[BaseInvocationOutput] | None:
+ """Gets the output class for a given output type."""
+ return cls.get_outputs_map().get(output_type)
+
+
RESERVED_NODE_ATTRIBUTE_FIELD_NAMES = {
"id",
"is_intermediate",
"use_cache",
"type",
"workflow",
+ "bottleneck",
+ "idle_gpu_offloadable",
}
RESERVED_INPUT_FIELD_NAMES = {"metadata", "board"}
-RESERVED_OUTPUT_FIELD_NAMES = {"type"}
+RESERVED_OUTPUT_FIELD_NAMES = {"type", "output_meta"}
class _Model(BaseModel):
@@ -349,6 +483,15 @@ class _Model(BaseModel):
RESERVED_PYDANTIC_FIELD_NAMES = {m[0] for m in inspect.getmembers(_Model())}
+def is_enum_member(value: Any, enum_class: type[Enum]) -> bool:
+ """Checks if a value is a member of an enum class."""
+ try:
+ enum_class(value)
+ return True
+ except ValueError:
+ return False
+
+
def validate_fields(model_fields: dict[str, FieldInfo], model_type: str) -> None:
"""
Validates the fields of an invocation or invocation output:
@@ -360,54 +503,144 @@ def validate_fields(model_fields: dict[str, FieldInfo], model_type: str) -> None
"""
for name, field in model_fields.items():
if name in RESERVED_PYDANTIC_FIELD_NAMES:
- raise InvalidFieldError(f'Invalid field name "{name}" on "{model_type}" (reserved by pydantic)')
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field name (reserved by pydantic)")
if not field.annotation:
- raise InvalidFieldError(f'Invalid field type "{name}" on "{model_type}" (missing annotation)')
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field type (missing annotation)")
if not isinstance(field.json_schema_extra, dict):
- raise InvalidFieldError(
- f'Invalid field definition for "{name}" on "{model_type}" (missing json_schema_extra dict)'
- )
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field definition (missing json_schema_extra dict)")
field_kind = field.json_schema_extra.get("field_kind", None)
# must have a field_kind
- if not isinstance(field_kind, FieldKind):
+ if not is_enum_member(field_kind, FieldKind):
raise InvalidFieldError(
- f'Invalid field definition for "{name}" on "{model_type}" (maybe it\'s not an InputField or OutputField?)'
+ f"{model_type}.{name}: Invalid field definition for (maybe it's not an InputField or OutputField?)"
)
- if field_kind is FieldKind.Input and (
+ if field_kind == FieldKind.Input.value and (
name in RESERVED_NODE_ATTRIBUTE_FIELD_NAMES or name in RESERVED_INPUT_FIELD_NAMES
):
- raise InvalidFieldError(f'Invalid field name "{name}" on "{model_type}" (reserved input field name)')
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field name (reserved input field name)")
- if field_kind is FieldKind.Output and name in RESERVED_OUTPUT_FIELD_NAMES:
- raise InvalidFieldError(f'Invalid field name "{name}" on "{model_type}" (reserved output field name)')
+ if field_kind == FieldKind.Output.value and name in RESERVED_OUTPUT_FIELD_NAMES:
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field name (reserved output field name)")
- if (field_kind is FieldKind.Internal) and name not in RESERVED_INPUT_FIELD_NAMES:
- raise InvalidFieldError(
- f'Invalid field name "{name}" on "{model_type}" (internal field without reserved name)'
- )
+ if field_kind == FieldKind.Internal.value and name not in RESERVED_INPUT_FIELD_NAMES:
+ raise InvalidFieldError(f"{model_type}.{name}: Invalid field name (internal field without reserved name)")
# node attribute fields *must* be in the reserved list
if (
- field_kind is FieldKind.NodeAttribute
+ field_kind == FieldKind.NodeAttribute.value
and name not in RESERVED_NODE_ATTRIBUTE_FIELD_NAMES
and name not in RESERVED_OUTPUT_FIELD_NAMES
):
raise InvalidFieldError(
- f'Invalid field name "{name}" on "{model_type}" (node attribute field without reserved name)'
+ f"{model_type}.{name}: Invalid field name (node attribute field without reserved name)"
)
ui_type = field.json_schema_extra.get("ui_type", None)
- if isinstance(ui_type, str) and ui_type.startswith("DEPRECATED_"):
- logger.warn(f"\"UIType.{ui_type.split('_')[-1]}\" is deprecated, ignoring")
- field.json_schema_extra.pop("ui_type")
+ ui_model_base = field.json_schema_extra.get("ui_model_base", None)
+ ui_model_type = field.json_schema_extra.get("ui_model_type", None)
+ ui_model_variant = field.json_schema_extra.get("ui_model_variant", None)
+ ui_model_format = field.json_schema_extra.get("ui_model_format", None)
+
+ if ui_type is not None:
+ # There are 3 cases where we may need to take action:
+ #
+ # 1. The ui_type is a migratable, deprecated value. For example, ui_type=UIType.MainModel value is
+ # deprecated and should be migrated to:
+ # - ui_model_base=[BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]
+ # - ui_model_type=[ModelType.Main]
+ #
+ # 2. ui_type was set in conjunction with any of the new ui_model_[base|type|variant|format] fields, which
+ # is not allowed (they are mutually exclusive). In this case, we ignore ui_type and log a warning.
+ #
+ # 3. ui_type is a deprecated value that is not migratable. For example, ui_type=UIType.Image is deprecated;
+ # Image fields are now automatically detected based on the field's type annotation. In this case, we
+ # ignore ui_type and log a warning.
+ #
+ # The cases must be checked in this order to ensure proper handling.
+
+ # Easier to work with as an enum
+ ui_type = UIType(ui_type)
+
+ # The enum member values are not always the same as their names - we want to log the name so the user can
+ # easily review their code and see where the deprecated enum member is used.
+ human_readable_name = f"UIType.{ui_type.name}"
+
+ # Case 1: migratable deprecated value
+ did_migrate = migrate_model_ui_type(ui_type, field.json_schema_extra)
+
+ if did_migrate:
+ logger.warning(
+ f'{model_type}.{name}: Migrated deprecated "ui_type" "{human_readable_name}" to new ui_model_[base|type|variant|format] fields'
+ )
+ field.json_schema_extra.pop("ui_type")
+
+ # Case 2: mutually exclusive with new fields
+ elif (
+ ui_model_base is not None
+ or ui_model_type is not None
+ or ui_model_variant is not None
+ or ui_model_format is not None
+ ):
+ logger.warning(
+ f'{model_type}.{name}: "ui_type" is mutually exclusive with "ui_model_[base|type|format|variant]", ignoring "ui_type"'
+ )
+ field.json_schema_extra.pop("ui_type")
+
+ # Case 3: deprecated value that is not migratable
+ elif ui_type.startswith("DEPRECATED_"):
+ logger.warning(f'{model_type}.{name}: Deprecated "ui_type" "{human_readable_name}", ignoring')
+ field.json_schema_extra.pop("ui_type")
+
return None
+class NoDefaultSentinel:
+ pass
+
+
+def validate_field_default(
+ cls_name: str, field_name: str, invocation_type: str, annotation: Any, field_info: FieldInfo
+) -> None:
+ """Validates the default value of a field against its pydantic field definition."""
+
+ assert isinstance(field_info.json_schema_extra, dict), "json_schema_extra is not a dict"
+
+ # By the time we are doing this, we've already done some pydantic magic by overriding the original default value.
+ # We store the original default value in the json_schema_extra dict, so we can validate it here.
+ orig_default = field_info.json_schema_extra.get("orig_default", NoDefaultSentinel)
+
+ if orig_default is NoDefaultSentinel:
+ return
+
+ # To validate the default value, we can create a temporary pydantic model with the field we are validating as its
+ # only field. Then validate the default value against this temporary model.
+ TempDefaultValidator = cast(BaseModel, create_model(cls_name, **{field_name: (annotation, field_info)}))
+
+ try:
+ TempDefaultValidator.model_validate({field_name: orig_default})
+ except Exception as e:
+ raise InvalidFieldError(
+ f'Default value for field "{field_name}" on invocation "{invocation_type}" is invalid, {e}'
+ ) from e
+
+
+def is_optional(annotation: Any) -> bool:
+ """
+ Checks if the given annotation is optional (i.e. Optional[X], Union[X, None] or X | None).
+ """
+ origin = typing.get_origin(annotation)
+ # PEP 604 unions (int|None) have origin types.UnionType
+ is_union = origin is typing.Union or origin is types.UnionType
+ if not is_union:
+ return False
+ return any(arg is type(None) for arg in typing.get_args(annotation))
+
+
def invocation(
invocation_type: str,
title: Optional[str] = None,
@@ -416,6 +649,8 @@ def invocation(
version: Optional[str] = None,
use_cache: Optional[bool] = True,
classification: Classification = Classification.Stable,
+ bottleneck: Bottleneck = Bottleneck.GPU,
+ idle_gpu_offloadable: bool = False,
) -> Callable[[Type[TBaseInvocation]], Type[TBaseInvocation]]:
"""
Registers an invocation.
@@ -427,6 +662,8 @@ def invocation(
:param Optional[str] version: Adds a version to the invocation. Must be a valid semver string. Defaults to None.
:param Optional[bool] use_cache: Whether or not to use the invocation cache. Defaults to True. The user may override this in the workflow editor.
:param Classification classification: The classification of the invocation. Defaults to FeatureClassification.Stable. Use Beta or Prototype if the invocation is unstable.
+ :param Bottleneck bottleneck: The bottleneck of the invocation. Defaults to Bottleneck.GPU. Use Network if the invocation is network-bound.
+ :param bool idle_gpu_offloadable: Whether this node's whole execution may run on a borrowed idle GPU when `offload_text_encoders_to_idle_gpus` is enabled. Only set True for encoder-only nodes that store their result on the CPU and do no work on the session's own GPU. Defaults to False.
"""
def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]:
@@ -435,40 +672,57 @@ def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]:
if re.compile(r"^\S+$").match(invocation_type) is None:
raise ValueError(f'"invocation_type" must consist of non-whitespace characters, got "{invocation_type}"')
- if invocation_type in BaseInvocation.get_invocation_types():
- raise ValueError(f'Invocation type "{invocation_type}" already exists')
+ # The node pack is the module name - will be "invokeai" for built-in nodes
+ node_pack = cls.__module__.split(".")[0]
validate_fields(cls.model_fields, invocation_type)
+ fields: dict[str, tuple[Any, FieldInfo]] = {}
+
+ original_model_fields: dict[str, OriginalModelField] = {}
+
+ for field_name, field_info in cls.model_fields.items():
+ annotation = field_info.annotation
+ assert annotation is not None, f"{field_name} on invocation {invocation_type} has no type annotation."
+ assert isinstance(field_info.json_schema_extra, dict), (
+ f"{field_name} on invocation {invocation_type} has a non-dict json_schema_extra, did you forget to use InputField?"
+ )
+
+ original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info)
+
+ validate_field_default(cls.__name__, field_name, invocation_type, annotation, field_info)
+
+ if field_info.default is None and not is_optional(annotation):
+ annotation = annotation | None
+
+ fields[field_name] = (annotation, field_info)
+
# Add OpenAPI schema extras
- uiconfig_name = cls.__qualname__ + ".UIConfig"
- if not hasattr(cls, "UIConfig") or cls.UIConfig.__qualname__ != uiconfig_name:
- cls.UIConfig = type(uiconfig_name, (UIConfigBase,), {})
- cls.UIConfig.title = title
- cls.UIConfig.tags = tags
- cls.UIConfig.category = category
- cls.UIConfig.classification = classification
-
- # Grab the node pack's name from the module name, if it's a custom node
- is_custom_node = cls.__module__.rsplit(".", 1)[0] == "invokeai.app.invocations"
- if is_custom_node:
- cls.UIConfig.node_pack = cls.__module__.split(".")[0]
- else:
- cls.UIConfig.node_pack = None
+ uiconfig: dict[str, Any] = {}
+ uiconfig["title"] = title
+ uiconfig["tags"] = tags
+ uiconfig["category"] = category
+ uiconfig["classification"] = classification
+ uiconfig["node_pack"] = node_pack
if version is not None:
try:
semver.Version.parse(version)
except ValueError as e:
raise InvalidVersionError(f'Invalid version string for node "{invocation_type}": "{version}"') from e
- cls.UIConfig.version = version
+ uiconfig["version"] = version
else:
- logger.warn(f'No version specified for node "{invocation_type}", using "1.0.0"')
- cls.UIConfig.version = "1.0.0"
+ logger.warning(f'No version specified for node "{invocation_type}", using "1.0.0"')
+ uiconfig["version"] = "1.0.0"
+
+ cls.UIConfig = UIConfigBase(**uiconfig)
if use_cache is not None:
cls.model_fields["use_cache"].default = use_cache
+ cls.bottleneck = bottleneck
+ cls.idle_gpu_offloadable = idle_gpu_offloadable
+
# Add the invocation type to the model.
# You'd be tempted to just add the type field and rebuild the model, like this:
@@ -478,24 +732,55 @@ def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]:
# Unfortunately, because the `GraphInvocation` uses a forward ref in its `graph` field's annotation, this does
# not work. Instead, we have to create a new class with the type field and patch the original class with it.
- invocation_type_annotation = Literal[invocation_type] # type: ignore
- invocation_type_field = Field(
- title="type", default=invocation_type, json_schema_extra={"field_kind": FieldKind.NodeAttribute}
+ invocation_type_annotation = Literal[invocation_type]
+
+ # Field() returns an instance of FieldInfo, but thanks to a pydantic implementation detail, it is _typed_ as Any.
+ # This cast makes the type annotation match the class's true type.
+ invocation_type_field_info = cast(
+ FieldInfo,
+ Field(title="type", default=invocation_type, json_schema_extra={"field_kind": FieldKind.NodeAttribute}),
)
+ fields["type"] = (invocation_type_annotation, invocation_type_field_info)
+
+ # Invocation outputs must be registered using the @invocation_output decorator, but it is possible that the
+ # output is registered _after_ this invocation is registered. It depends on module import ordering.
+ #
+ # We can only confirm the output for an invocation is registered after all modules are imported. There's
+ # only really one good time to do that - during application startup, in `run_app.py`, after loading all
+ # custom nodes.
+ #
+ # We can still do some basic validation here - ensure the invoke method is defined and returns an instance
+ # of BaseInvocationOutput.
+
+ # Validate the `invoke()` method is implemented
+ if "invoke" in cls.__abstractmethods__:
+ raise ValueError(f'Invocation "{invocation_type}" must implement the "invoke" method')
+
+ # And validate that `invoke()` returns a subclass of `BaseInvocationOutput
+ invoke_return_annotation = signature(cls.invoke).return_annotation
+
+ try:
+ # TODO(psyche): If `invoke()` is not defined, `return_annotation` ends up as the string "BaseInvocationOutput"
+ # instead of the class `BaseInvocationOutput`. This may be a pydantic bug: https://github.com/pydantic/pydantic/issues/7978
+ if isinstance(invoke_return_annotation, str):
+ invoke_return_annotation = getattr(sys.modules[cls.__module__], invoke_return_annotation)
+
+ assert invoke_return_annotation is not BaseInvocationOutput
+ assert issubclass(invoke_return_annotation, BaseInvocationOutput)
+ except Exception:
+ raise ValueError(
+ f'Invocation "{invocation_type}" must have a return annotation of a subclass of BaseInvocationOutput (got "{invoke_return_annotation}")'
+ )
+
docstring = cls.__doc__
- cls = create_model(
- cls.__qualname__,
- __base__=cls,
- __module__=cls.__module__,
- type=(invocation_type_annotation, invocation_type_field),
- )
- cls.__doc__ = docstring
+ new_class = create_model(cls.__qualname__, __base__=cls, __module__=cls.__module__, **fields) # type: ignore
+ new_class.__doc__ = docstring
+ new_class._original_model_fields = original_model_fields
- # TODO: how to type this correctly? it's typed as ModelMetaclass, a private class in pydantic
- BaseInvocation.register_invocation(cls) # type: ignore
+ InvocationRegistry.register_invocation(new_class)
- return cls
+ return new_class
return wrapper
@@ -518,29 +803,41 @@ def wrapper(cls: Type[TBaseInvocationOutput]) -> Type[TBaseInvocationOutput]:
if re.compile(r"^\S+$").match(output_type) is None:
raise ValueError(f'"output_type" must consist of non-whitespace characters, got "{output_type}"')
- if output_type in BaseInvocationOutput.get_output_types():
- raise ValueError(f'Invocation type "{output_type}" already exists')
-
validate_fields(cls.model_fields, output_type)
+ fields: dict[str, tuple[Any, FieldInfo]] = {}
+
+ for field_name, field_info in cls.model_fields.items():
+ annotation = field_info.annotation
+ assert annotation is not None, f"{field_name} on invocation output {output_type} has no type annotation."
+ assert isinstance(field_info.json_schema_extra, dict), (
+ f"{field_name} on invocation output {output_type} has a non-dict json_schema_extra, did you forget to use InputField?"
+ )
+
+ cls._original_model_fields[field_name] = OriginalModelField(annotation=annotation, field_info=field_info)
+
+ if field_info.default is not PydanticUndefined and is_optional(annotation):
+ annotation = annotation | None
+ fields[field_name] = (annotation, field_info)
+
# Add the output type to the model.
+ output_type_annotation = Literal[output_type]
- output_type_annotation = Literal[output_type] # type: ignore
- output_type_field = Field(
- title="type", default=output_type, json_schema_extra={"field_kind": FieldKind.NodeAttribute}
+ # Field() returns an instance of FieldInfo, but thanks to a pydantic implementation detail, it is _typed_ as Any.
+ # This cast makes the type annotation match the class's true type.
+ output_type_field_info = cast(
+ FieldInfo,
+ Field(title="type", default=output_type, json_schema_extra={"field_kind": FieldKind.NodeAttribute}),
)
+ fields["type"] = (output_type_annotation, output_type_field_info)
+
docstring = cls.__doc__
- cls = create_model(
- cls.__qualname__,
- __base__=cls,
- __module__=cls.__module__,
- type=(output_type_annotation, output_type_field),
- )
- cls.__doc__ = docstring
+ new_class = create_model(cls.__qualname__, __base__=cls, __module__=cls.__module__, **fields)
+ new_class.__doc__ = docstring
- BaseInvocationOutput.register_output(cls) # type: ignore # TODO: how to type this correctly?
+ InvocationRegistry.register_output(new_class)
- return cls
+ return new_class
return wrapper
diff --git a/invokeai/app/invocations/batch.py b/invokeai/app/invocations/batch.py
new file mode 100644
index 00000000000..f79b8816ade
--- /dev/null
+++ b/invokeai/app/invocations/batch.py
@@ -0,0 +1,270 @@
+from typing import Literal
+
+from pydantic import BaseModel
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ ImageField,
+ Input,
+ InputField,
+ OutputField,
+)
+from invokeai.app.invocations.primitives import (
+ FloatOutput,
+ ImageOutput,
+ IntegerOutput,
+ StringOutput,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+BATCH_GROUP_IDS = Literal[
+ "None",
+ "Group 1",
+ "Group 2",
+ "Group 3",
+ "Group 4",
+ "Group 5",
+]
+
+
+class NotExecutableNodeError(Exception):
+ def __init__(self, message: str = "This class should never be executed or instantiated directly."):
+ super().__init__(message)
+
+ pass
+
+
+class BaseBatchInvocation(BaseInvocation):
+ batch_group_id: BATCH_GROUP_IDS = InputField(
+ default="None",
+ description="The ID of this batch node's group. If provided, all batch nodes in with the same ID will be 'zipped' before execution, and all nodes' collections must be of the same size.",
+ input=Input.Direct,
+ title="Batch Group",
+ )
+
+ def __init__(self):
+ raise NotExecutableNodeError()
+
+
+@invocation(
+ "image_batch",
+ title="Image Batch",
+ tags=["primitives", "image", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class ImageBatchInvocation(BaseBatchInvocation):
+ """Create a batched generation, where the workflow is executed once for each image in the batch."""
+
+ images: list[ImageField] = InputField(
+ min_length=1,
+ description="The images to batch over",
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation_output("image_generator_output")
+class ImageGeneratorOutput(BaseInvocationOutput):
+ """Base class for nodes that output a collection of boards"""
+
+ images: list[ImageField] = OutputField(description="The generated images")
+
+
+class ImageGeneratorField(BaseModel):
+ pass
+
+
+@invocation(
+ "image_generator",
+ title="Image Generator",
+ tags=["primitives", "board", "image", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class ImageGenerator(BaseInvocation):
+ """Generated a collection of images for use in a batched generation"""
+
+ generator: ImageGeneratorField = InputField(
+ description="The image generator.",
+ input=Input.Direct,
+ title="Generator Type",
+ )
+
+ def __init__(self):
+ raise NotExecutableNodeError()
+
+ def invoke(self, context: InvocationContext) -> ImageGeneratorOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation(
+ "string_batch",
+ title="String Batch",
+ tags=["primitives", "string", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class StringBatchInvocation(BaseBatchInvocation):
+ """Create a batched generation, where the workflow is executed once for each string in the batch."""
+
+ strings: list[str] = InputField(
+ min_length=1,
+ description="The strings to batch over",
+ )
+
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation_output("string_generator_output")
+class StringGeneratorOutput(BaseInvocationOutput):
+ """Base class for nodes that output a collection of strings"""
+
+ strings: list[str] = OutputField(description="The generated strings")
+
+
+class StringGeneratorField(BaseModel):
+ pass
+
+
+@invocation(
+ "string_generator",
+ title="String Generator",
+ tags=["primitives", "string", "number", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class StringGenerator(BaseInvocation):
+ """Generated a range of strings for use in a batched generation"""
+
+ generator: StringGeneratorField = InputField(
+ description="The string generator.",
+ input=Input.Direct,
+ title="Generator Type",
+ )
+
+ def __init__(self):
+ raise NotExecutableNodeError()
+
+ def invoke(self, context: InvocationContext) -> StringGeneratorOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation(
+ "integer_batch",
+ title="Integer Batch",
+ tags=["primitives", "integer", "number", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class IntegerBatchInvocation(BaseBatchInvocation):
+ """Create a batched generation, where the workflow is executed once for each integer in the batch."""
+
+ integers: list[int] = InputField(
+ min_length=1,
+ description="The integers to batch over",
+ )
+
+ def invoke(self, context: InvocationContext) -> IntegerOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation_output("integer_generator_output")
+class IntegerGeneratorOutput(BaseInvocationOutput):
+ integers: list[int] = OutputField(description="The generated integers")
+
+
+class IntegerGeneratorField(BaseModel):
+ pass
+
+
+@invocation(
+ "integer_generator",
+ title="Integer Generator",
+ tags=["primitives", "int", "number", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class IntegerGenerator(BaseInvocation):
+ """Generated a range of integers for use in a batched generation"""
+
+ generator: IntegerGeneratorField = InputField(
+ description="The integer generator.",
+ input=Input.Direct,
+ title="Generator Type",
+ )
+
+ def __init__(self):
+ raise NotExecutableNodeError()
+
+ def invoke(self, context: InvocationContext) -> IntegerGeneratorOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation(
+ "float_batch",
+ title="Float Batch",
+ tags=["primitives", "float", "number", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class FloatBatchInvocation(BaseBatchInvocation):
+ """Create a batched generation, where the workflow is executed once for each float in the batch."""
+
+ floats: list[float] = InputField(
+ min_length=1,
+ description="The floats to batch over",
+ )
+
+ def invoke(self, context: InvocationContext) -> FloatOutput:
+ raise NotExecutableNodeError()
+
+
+@invocation_output("float_generator_output")
+class FloatGeneratorOutput(BaseInvocationOutput):
+ """Base class for nodes that output a collection of floats"""
+
+ floats: list[float] = OutputField(description="The generated floats")
+
+
+class FloatGeneratorField(BaseModel):
+ pass
+
+
+@invocation(
+ "float_generator",
+ title="Float Generator",
+ tags=["primitives", "float", "number", "batch", "special"],
+ category="batch",
+ version="1.0.0",
+ classification=Classification.Special,
+)
+class FloatGenerator(BaseInvocation):
+ """Generated a range of floats for use in a batched generation"""
+
+ generator: FloatGeneratorField = InputField(
+ description="The float generator.",
+ input=Input.Direct,
+ title="Generator Type",
+ )
+
+ def __init__(self):
+ raise NotExecutableNodeError()
+
+ def invoke(self, context: InvocationContext) -> FloatGeneratorOutput:
+ raise NotExecutableNodeError()
diff --git a/invokeai/app/invocations/blend_latents.py b/invokeai/app/invocations/blend_latents.py
index 9238f4b34c5..9f4e0f5563c 100644
--- a/invokeai/app/invocations/blend_latents.py
+++ b/invokeai/app/invocations/blend_latents.py
@@ -1,98 +1,120 @@
-from typing import Any, Union
+from typing import Optional, Union
import numpy as np
-import numpy.typing as npt
import torch
+import torchvision.transforms as T
+from PIL import Image
+from torchvision.transforms.functional import resize as tv_resize
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, LatentsField
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, Input, InputField, LatentsField
from invokeai.app.invocations.primitives import LatentsOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
from invokeai.backend.util.devices import TorchDevice
+def slerp(
+ t: Union[float, np.ndarray],
+ v0: Union[torch.Tensor, np.ndarray],
+ v1: Union[torch.Tensor, np.ndarray],
+ device: torch.device,
+ DOT_THRESHOLD: float = 0.9995,
+):
+ """
+ Spherical linear interpolation
+ Args:
+ t (float/np.ndarray): Float value between 0.0 and 1.0
+ v0 (np.ndarray): Starting vector
+ v1 (np.ndarray): Final vector
+ DOT_THRESHOLD (float): Threshold for considering the two vectors as
+ colineal. Not recommended to alter this.
+ Returns:
+ v2 (np.ndarray): Interpolation vector between v0 and v1
+ """
+ inputs_are_torch = False
+ if not isinstance(v0, np.ndarray):
+ inputs_are_torch = True
+ v0 = v0.detach().cpu().numpy()
+ if not isinstance(v1, np.ndarray):
+ inputs_are_torch = True
+ v1 = v1.detach().cpu().numpy()
+
+ dot = np.sum(v0 * v1 / (np.linalg.norm(v0) * np.linalg.norm(v1)))
+ if np.abs(dot) > DOT_THRESHOLD:
+ v2 = (1 - t) * v0 + t * v1
+ else:
+ theta_0 = np.arccos(dot)
+ sin_theta_0 = np.sin(theta_0)
+ theta_t = theta_0 * t
+ sin_theta_t = np.sin(theta_t)
+ s0 = np.sin(theta_0 - theta_t) / sin_theta_0
+ s1 = sin_theta_t / sin_theta_0
+ v2 = s0 * v0 + s1 * v1
+
+ if inputs_are_torch:
+ v2 = torch.from_numpy(v2).to(device)
+
+ return v2
+
+
@invocation(
"lblend",
title="Blend Latents",
- tags=["latents", "blend"],
+ tags=["latents", "blend", "mask"],
category="latents",
- version="1.0.3",
+ version="1.1.0",
)
class BlendLatentsInvocation(BaseInvocation):
- """Blend two latents using a given alpha. Latents must have same size."""
-
- latents_a: LatentsField = InputField(
- description=FieldDescriptions.latents,
- input=Input.Connection,
- )
- latents_b: LatentsField = InputField(
- description=FieldDescriptions.latents,
- input=Input.Connection,
- )
- alpha: float = InputField(default=0.5, description=FieldDescriptions.blend_alpha)
+ """Blend two latents using a given alpha. If a mask is provided, the second latents will be masked before blending.
+ Latents must have same size. Masking functionality added by @dwringer."""
+
+ latents_a: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ latents_b: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ mask: Optional[ImageField] = InputField(default=None, description="Mask for blending in latents B")
+ alpha: float = InputField(ge=0, default=0.5, description=FieldDescriptions.blend_alpha)
+
+ def prep_mask_tensor(self, mask_image: Image.Image) -> torch.Tensor:
+ if mask_image.mode != "L":
+ mask_image = mask_image.convert("L")
+ mask_tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False)
+ if mask_tensor.dim() == 3:
+ mask_tensor = mask_tensor.unsqueeze(0)
+ return mask_tensor
+
+ def replace_tensor_from_masked_tensor(
+ self, tensor: torch.Tensor, other_tensor: torch.Tensor, mask_tensor: torch.Tensor
+ ):
+ output = tensor.clone()
+ mask_tensor = mask_tensor.expand(output.shape)
+ if output.dtype != torch.float16:
+ output = torch.add(output, mask_tensor * torch.sub(other_tensor, tensor))
+ else:
+ output = torch.add(output, mask_tensor.half() * torch.sub(other_tensor, tensor))
+ return output
def invoke(self, context: InvocationContext) -> LatentsOutput:
latents_a = context.tensors.load(self.latents_a.latents_name)
latents_b = context.tensors.load(self.latents_b.latents_name)
+ if self.mask is None:
+ mask_tensor = torch.zeros(latents_a.shape[-2:])
+ else:
+ mask_tensor = self.prep_mask_tensor(context.images.get_pil(self.mask.image_name))
+ mask_tensor = tv_resize(mask_tensor, latents_a.shape[-2:], T.InterpolationMode.BILINEAR, antialias=False)
+
+ latents_b = self.replace_tensor_from_masked_tensor(latents_b, latents_a, mask_tensor)
if latents_a.shape != latents_b.shape:
- raise Exception("Latents to blend must be the same size.")
+ raise ValueError("Latents to blend must be the same size.")
device = TorchDevice.choose_torch_device()
- def slerp(
- t: Union[float, npt.NDArray[Any]], # FIXME: maybe use np.float32 here?
- v0: Union[torch.Tensor, npt.NDArray[Any]],
- v1: Union[torch.Tensor, npt.NDArray[Any]],
- DOT_THRESHOLD: float = 0.9995,
- ) -> Union[torch.Tensor, npt.NDArray[Any]]:
- """
- Spherical linear interpolation
- Args:
- t (float/np.ndarray): Float value between 0.0 and 1.0
- v0 (np.ndarray): Starting vector
- v1 (np.ndarray): Final vector
- DOT_THRESHOLD (float): Threshold for considering the two vectors as
- colineal. Not recommended to alter this.
- Returns:
- v2 (np.ndarray): Interpolation vector between v0 and v1
- """
- inputs_are_torch = False
- if not isinstance(v0, np.ndarray):
- inputs_are_torch = True
- v0 = v0.detach().cpu().numpy()
- if not isinstance(v1, np.ndarray):
- inputs_are_torch = True
- v1 = v1.detach().cpu().numpy()
-
- dot = np.sum(v0 * v1 / (np.linalg.norm(v0) * np.linalg.norm(v1)))
- if np.abs(dot) > DOT_THRESHOLD:
- v2 = (1 - t) * v0 + t * v1
- else:
- theta_0 = np.arccos(dot)
- sin_theta_0 = np.sin(theta_0)
- theta_t = theta_0 * t
- sin_theta_t = np.sin(theta_t)
- s0 = np.sin(theta_0 - theta_t) / sin_theta_0
- s1 = sin_theta_t / sin_theta_0
- v2 = s0 * v0 + s1 * v1
-
- if inputs_are_torch:
- v2_torch: torch.Tensor = torch.from_numpy(v2).to(device)
- return v2_torch
- else:
- assert isinstance(v2, np.ndarray)
- return v2
-
# blend
- bl = slerp(self.alpha, latents_a, latents_b)
- assert isinstance(bl, torch.Tensor)
- blended_latents: torch.Tensor = bl # for type checking convenience
+ blended_latents = slerp(self.alpha, latents_a, latents_b, device)
# https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699
blended_latents = blended_latents.to("cpu")
-
- TorchDevice.empty_cache()
+ torch.cuda.empty_cache()
name = context.tensors.save(tensor=blended_latents)
- return LatentsOutput.build(latents_name=name, latents=blended_latents, seed=self.latents_a.seed)
+ return LatentsOutput.build(latents_name=name, latents=blended_latents)
diff --git a/invokeai/app/invocations/canny.py b/invokeai/app/invocations/canny.py
new file mode 100644
index 00000000000..dbfde6d3539
--- /dev/null
+++ b/invokeai/app/invocations/canny.py
@@ -0,0 +1,34 @@
+import cv2
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.util import cv2_to_pil, pil_to_cv2
+
+
+@invocation(
+ "canny_edge_detection",
+ title="Canny Edge Detection",
+ tags=["controlnet", "canny"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class CannyEdgeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Geneartes an edge map using a cv2's Canny algorithm."""
+
+ image: ImageField = InputField(description="The image to process")
+ low_threshold: int = InputField(
+ default=100, ge=0, le=255, description="The low threshold of the Canny pixel gradient (0-255)"
+ )
+ high_threshold: int = InputField(
+ default=200, ge=0, le=255, description="The high threshold of the Canny pixel gradient (0-255)"
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ np_img = pil_to_cv2(image)
+ edge_map = cv2.Canny(np_img, self.low_threshold, self.high_threshold)
+ edge_map_pil = cv2_to_pil(edge_map)
+ image_dto = context.images.save(image=edge_map_pil)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/canvas.py b/invokeai/app/invocations/canvas.py
new file mode 100644
index 00000000000..cf13c3334ff
--- /dev/null
+++ b/invokeai/app/invocations/canvas.py
@@ -0,0 +1,27 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation(
+ "canvas_output",
+ title="Canvas Output",
+ tags=["canvas", "output", "image"],
+ category="canvas",
+ version="1.0.0",
+ use_cache=False,
+)
+class CanvasOutputInvocation(BaseInvocation):
+ """Outputs an image to the canvas staging area.
+
+ Use this node in workflows intended for canvas workflow integration.
+ Connect the final image of your workflow to this node to send it
+ to the canvas staging area when run via 'Run Workflow on Canvas'."""
+
+ image: ImageField = InputField(description=FieldDescriptions.image)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name)
+ image_dto = context.images.save(image=image)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/cogview4_denoise.py b/invokeai/app/invocations/cogview4_denoise.py
new file mode 100644
index 00000000000..cb06d2b3ff6
--- /dev/null
+++ b/invokeai/app/invocations/cogview4_denoise.py
@@ -0,0 +1,377 @@
+from typing import Callable, Optional
+
+import torch
+import torchvision.transforms as tv_transforms
+from diffusers.models.transformers.transformer_cogview4 import CogView4Transformer2DModel
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.fields import (
+ CogView4ConditioningField,
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import TransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional
+from invokeai.backend.model_manager.taxonomy import BaseModelType
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import CogView4ConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "cogview4_denoise",
+ title="Denoise - CogView4",
+ tags=["image", "cogview4"],
+ category="latents",
+ version="1.1.0",
+ classification=Classification.Prototype,
+)
+class CogView4DenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Run the denoising process with a CogView4 model."""
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.latents, input=Input.Connection
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.noise, input=Input.Connection
+ )
+ # denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
+ )
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.cogview4_model, input=Input.Connection, title="Transformer"
+ )
+ positive_conditioning: CogView4ConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: CogView4ConditioningField = InputField(
+ description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ cfg_scale: float | list[float] = InputField(default=3.5, description=FieldDescriptions.cfg_scale, title="CFG Scale")
+ width: int = InputField(default=1024, multiple_of=32, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=32, description="Height of the generated image.")
+ steps: int = InputField(default=25, gt=0, description=FieldDescriptions.steps)
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ """Prepare the inpaint mask.
+ - Loads the mask
+ - Resizes if necessary
+ - Casts to same device/dtype as latents
+
+ Args:
+ context (InvocationContext): The invocation context, for loading the inpaint mask.
+ latents (torch.Tensor): A latent image tensor. Used to determine the target shape, device, and dtype for the
+ inpaint mask.
+
+ Returns:
+ torch.Tensor | None: Inpaint mask. Values of 0.0 represent the regions to be fully denoised, and 1.0
+ represent the regions to be preserved.
+ """
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+
+ # The input denoise_mask contains values in [0, 1], where 0.0 represents the regions to be fully denoised, and
+ # 1.0 represents the regions to be preserved.
+ # We invert the mask so that the regions to be preserved are 0.0 and the regions to be denoised are 1.0.
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask
+
+ def _load_text_conditioning(
+ self,
+ context: InvocationContext,
+ conditioning_name: str,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> torch.Tensor:
+ # Load the conditioning data.
+ cond_data = context.conditioning.load(conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ cogview4_conditioning = cond_data.conditionings[0]
+ assert isinstance(cogview4_conditioning, CogView4ConditioningInfo)
+ cogview4_conditioning = cogview4_conditioning.to(dtype=dtype, device=device)
+
+ return cogview4_conditioning.glm_embeds
+
+ def _get_noise(
+ self,
+ batch_size: int,
+ num_channels_latents: int,
+ height: int,
+ width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ seed: int,
+ ) -> torch.Tensor:
+ # We always generate noise on the same device and dtype then cast to ensure consistency across devices/dtypes.
+ rand_device = "cpu"
+ rand_dtype = torch.float16
+
+ return torch.randn(
+ batch_size,
+ num_channels_latents,
+ int(height) // LATENT_SCALE_FACTOR,
+ int(width) // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+ def _prepare_cfg_scale(self, num_timesteps: int) -> list[float]:
+ """Prepare the CFG scale list.
+
+ Args:
+ num_timesteps (int): The number of timesteps in the scheduler. Could be different from num_steps depending
+ on the scheduler used (e.g. higher order schedulers).
+
+ Returns:
+ list[float]: _description_
+ """
+ if isinstance(self.cfg_scale, float):
+ cfg_scale = [self.cfg_scale] * num_timesteps
+ elif isinstance(self.cfg_scale, list):
+ assert len(self.cfg_scale) == num_timesteps
+ cfg_scale = self.cfg_scale
+ else:
+ raise ValueError(f"Invalid CFG scale type: {type(self.cfg_scale)}")
+
+ return cfg_scale
+
+ def _convert_timesteps_to_sigmas(self, image_seq_len: int, timesteps: torch.Tensor) -> list[float]:
+ # The logic to prepare the timestep / sigma schedule is based on:
+ # https://github.com/huggingface/diffusers/blob/b38450d5d2e5b87d5ff7088ee5798c85587b9635/src/diffusers/pipelines/cogview4/pipeline_cogview4.py#L575-L595
+ # The default FlowMatchEulerDiscreteScheduler configs are based on:
+ # https://huggingface.co/THUDM/CogView4-6B/blob/fb6f57289c73ac6d139e8d81bd5a4602d1877847/scheduler/scheduler_config.json
+ # This implementation differs slightly from the original for the sake of simplicity (differs in terminal value
+ # handling, not quantizing timesteps to integers, etc.).
+
+ def calculate_timestep_shift(
+ image_seq_len: int, base_seq_len: int = 256, base_shift: float = 0.25, max_shift: float = 0.75
+ ) -> float:
+ m = (image_seq_len / base_seq_len) ** 0.5
+ mu = m * max_shift + base_shift
+ return mu
+
+ def time_shift_linear(mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor:
+ return mu / (mu + (1 / t - 1) ** sigma)
+
+ mu = calculate_timestep_shift(image_seq_len)
+ sigmas = time_shift_linear(mu, 1.0, timesteps)
+ return sigmas.tolist()
+
+ def _run_diffusion(
+ self,
+ context: InvocationContext,
+ ):
+ inference_dtype = torch.bfloat16
+ device = TorchDevice.choose_torch_device()
+
+ transformer_info = context.models.load(self.transformer.transformer)
+ assert isinstance(transformer_info.model, CogView4Transformer2DModel)
+
+ # Load/process the conditioning data.
+ # TODO(ryand): Make CFG optional.
+ do_classifier_free_guidance = True
+ pos_prompt_embeds = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.positive_conditioning.conditioning_name,
+ dtype=inference_dtype,
+ device=device,
+ )
+ neg_prompt_embeds = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.negative_conditioning.conditioning_name,
+ dtype=inference_dtype,
+ device=device,
+ )
+
+ # Prepare misc. conditioning variables.
+ # TODO(ryand): We could expose these as params (like with SDXL). But, we should experiment to see if they are
+ # useful first.
+ original_size = torch.tensor([(self.height, self.width)], dtype=pos_prompt_embeds.dtype, device=device)
+ target_size = torch.tensor([(self.height, self.width)], dtype=pos_prompt_embeds.dtype, device=device)
+ crops_coords_top_left = torch.tensor([(0, 0)], dtype=pos_prompt_embeds.dtype, device=device)
+
+ # Prepare the timestep / sigma schedule.
+ patch_size = transformer_info.model.config.patch_size # type: ignore
+ assert isinstance(patch_size, int)
+ image_seq_len = ((self.height // LATENT_SCALE_FACTOR) * (self.width // LATENT_SCALE_FACTOR)) // (patch_size**2)
+ # We add an extra step to the end to account for the final timestep of 0.0.
+ timesteps: list[float] = torch.linspace(1, 0, self.steps + 1).tolist()
+ # Clip the timesteps schedule based on denoising_start and denoising_end.
+ timesteps = clip_timestep_schedule_fractional(timesteps, self.denoising_start, self.denoising_end)
+ sigmas = self._convert_timesteps_to_sigmas(image_seq_len, torch.tensor(timesteps))
+ total_steps = len(timesteps) - 1
+
+ # Prepare the CFG scale list.
+ cfg_scale = self._prepare_cfg_scale(total_steps)
+
+ # Load the input latents, if provided.
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+
+ # Generate initial latent noise.
+ num_channels_latents = transformer_info.model.config.in_channels # type: ignore
+ assert isinstance(num_channels_latents, int)
+ noise = self._prepare_noise_tensor(context, num_channels_latents, inference_dtype, device)
+
+ # Prepare input latent image.
+ if init_latents is not None:
+ # Noise the init_latents by the appropriate amount for the first timestep.
+ s_0 = sigmas[0]
+ latents = s_0 * noise + (1.0 - s_0) * init_latents
+ else:
+ # init_latents are not provided, so we are not doing image-to-image (i.e. we are starting from pure noise).
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ latents = noise
+
+ # If len(timesteps) == 1, then short-circuit. We are just noising the input latents, but not taking any
+ # denoising steps.
+ if len(timesteps) <= 1:
+ return latents
+
+ # Prepare inpaint extension.
+ inpaint_mask = self._prep_inpaint_mask(context, latents)
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ assert init_latents is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents,
+ inpaint_mask=inpaint_mask,
+ noise=noise,
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=0,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(timesteps[0]),
+ latents=latents,
+ ),
+ )
+
+ with transformer_info.model_on_device() as (_, transformer):
+ assert isinstance(transformer, CogView4Transformer2DModel)
+
+ # Denoising loop
+ for step_idx in tqdm(range(total_steps), desc=f"Denoising{TorchDevice.get_session_device_label()}"):
+ t_curr = timesteps[step_idx]
+ sigma_curr = sigmas[step_idx]
+ sigma_prev = sigmas[step_idx + 1]
+
+ # Expand the timestep to match the latent model input.
+ # Multiply by 1000 to match the default FlowMatchEulerDiscreteScheduler num_train_timesteps.
+ timestep = torch.tensor([t_curr * 1000], device=device).expand(latents.shape[0])
+
+ # TODO(ryand): Support both sequential and batched CFG inference.
+ noise_pred_cond = transformer(
+ hidden_states=latents,
+ encoder_hidden_states=pos_prompt_embeds,
+ timestep=timestep,
+ original_size=original_size,
+ target_size=target_size,
+ crop_coords=crops_coords_top_left,
+ return_dict=False,
+ )[0]
+
+ # Apply CFG.
+ if do_classifier_free_guidance:
+ noise_pred_uncond = transformer(
+ hidden_states=latents,
+ encoder_hidden_states=neg_prompt_embeds,
+ timestep=timestep,
+ original_size=original_size,
+ target_size=target_size,
+ crop_coords=crops_coords_top_left,
+ return_dict=False,
+ )[0]
+
+ noise_pred = noise_pred_uncond + cfg_scale[step_idx] * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ # Compute the previous noisy sample x_t -> x_t-1.
+ latents_dtype = latents.dtype
+ # TODO(ryand): Is casting to float32 necessary for precision/stability? I copied this from SD3.
+ latents = latents.to(dtype=torch.float32)
+ latents = latents + (sigma_prev - sigma_curr) * noise_pred
+ latents = latents.to(dtype=latents_dtype)
+
+ if inpaint_extension is not None:
+ latents = inpaint_extension.merge_intermediate_latents_with_init_latents(latents, sigma_prev)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(t_curr),
+ latents=latents,
+ ),
+ )
+
+ return latents
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, num_channels_latents: int, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "CogView4", self.width, self.height)
+ return noise
+
+ return self._get_noise(
+ batch_size=1,
+ num_channels_latents=num_channels_latents,
+ height=self.height,
+ width=self.width,
+ dtype=inference_dtype,
+ device=device,
+ seed=self.seed,
+ )
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.CogView4)
+
+ return step_callback
diff --git a/invokeai/app/invocations/cogview4_image_to_latents.py b/invokeai/app/invocations/cogview4_image_to_latents.py
new file mode 100644
index 00000000000..facbc38dd42
--- /dev/null
+++ b/invokeai/app/invocations/cogview4_image_to_latents.py
@@ -0,0 +1,76 @@
+import einops
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_cogview4
+
+# TODO(ryand): This is effectively a copy of SD3ImageToLatentsInvocation and a subset of ImageToLatentsInvocation. We
+# should refactor to avoid this duplication.
+
+
+@invocation(
+ "cogview4_i2l",
+ title="Image to Latents - CogView4",
+ tags=["image", "latents", "vae", "i2l", "cogview4"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class CogView4ImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates latents from an image."""
+
+ image: ImageField = InputField(description="The image to encode.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ assert isinstance(vae_info.model, AutoencoderKL)
+ estimated_working_memory = estimate_vae_working_memory_cogview4(
+ operation="encode", image_tensor=image_tensor, vae=vae_info.model
+ )
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoencoderKL)
+
+ vae.disable_tiling()
+
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae.dtype)
+ with torch.inference_mode():
+ image_tensor_dist = vae.encode(image_tensor).latent_dist
+ # TODO: Use seed to make sampling reproducible.
+ latents: torch.Tensor = image_tensor_dist.sample().to(dtype=vae.dtype)
+
+ latents = vae.config.scaling_factor * latents
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, AutoencoderKL)
+
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/cogview4_latents_to_image.py b/invokeai/app/invocations/cogview4_latents_to_image.py
new file mode 100644
index 00000000000..1b77ed8a1f8
--- /dev/null
+++ b/invokeai/app/invocations/cogview4_latents_to_image.py
@@ -0,0 +1,79 @@
+from contextlib import nullcontext
+
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_cogview4
+
+# TODO(ryand): This is effectively a copy of SD3LatentsToImageInvocation and a subset of LatentsToImageInvocation. We
+# should refactor to avoid this duplication.
+
+
+@invocation(
+ "cogview4_l2i",
+ title="Latents to Image - CogView4",
+ tags=["latents", "image", "vae", "l2i", "cogview4"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class CogView4LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents."""
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, (AutoencoderKL))
+ estimated_working_memory = estimate_vae_working_memory_cogview4(
+ operation="decode", image_tensor=latents, vae=vae_info.model
+ )
+ with (
+ SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes),
+ vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae),
+ ):
+ context.util.signal_progress("Running VAE")
+ assert isinstance(vae, (AutoencoderKL))
+ latents = latents.to(TorchDevice.choose_torch_device())
+
+ vae.disable_tiling()
+
+ tiling_context = nullcontext()
+
+ # clear memory as vae decode can request a lot
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode(), tiling_context:
+ # copied from diffusers pipeline
+ latents = latents / vae.config.scaling_factor
+ img = vae.decode(latents, return_dict=False)[0]
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c") # noqa: F821
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/cogview4_model_loader.py b/invokeai/app/invocations/cogview4_model_loader.py
new file mode 100644
index 00000000000..fbafcd345fd
--- /dev/null
+++ b/invokeai/app/invocations/cogview4_model_loader.py
@@ -0,0 +1,56 @@
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ GlmEncoderField,
+ ModelIdentifierField,
+ TransformerField,
+ VAEField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
+
+
+@invocation_output("cogview4_model_loader_output")
+class CogView4ModelLoaderOutput(BaseInvocationOutput):
+ """CogView4 base model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ glm_encoder: GlmEncoderField = OutputField(description=FieldDescriptions.glm_encoder, title="GLM Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "cogview4_model_loader",
+ title="Main Model - CogView4",
+ tags=["model", "cogview4"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class CogView4ModelLoaderInvocation(BaseInvocation):
+ """Loads a CogView4 base model, outputting its submodels."""
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.cogview4_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.CogView4,
+ ui_model_type=ModelType.Main,
+ )
+
+ def invoke(self, context: InvocationContext) -> CogView4ModelLoaderOutput:
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ glm_tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ glm_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+
+ return CogView4ModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ glm_encoder=GlmEncoderField(tokenizer=glm_tokenizer, text_encoder=glm_encoder),
+ vae=VAEField(vae=vae),
+ )
diff --git a/invokeai/app/invocations/cogview4_text_encoder.py b/invokeai/app/invocations/cogview4_text_encoder.py
new file mode 100644
index 00000000000..c303e55b828
--- /dev/null
+++ b/invokeai/app/invocations/cogview4_text_encoder.py
@@ -0,0 +1,101 @@
+import torch
+from transformers import GlmModel, PreTrainedTokenizerFast
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, UIComponent
+from invokeai.app.invocations.model import GlmEncoderField
+from invokeai.app.invocations.primitives import CogView4ConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ CogView4ConditioningInfo,
+ ConditioningFieldData,
+)
+
+# The CogView4 GLM Text Encoder max sequence length set based on the default in diffusers.
+COGVIEW4_GLM_MAX_SEQ_LEN = 1024
+
+
+@invocation(
+ "cogview4_text_encoder",
+ title="Prompt - CogView4",
+ tags=["prompt", "conditioning", "cogview4"],
+ category="prompt",
+ version="1.0.0",
+ classification=Classification.Prototype,
+ idle_gpu_offloadable=True,
+)
+class CogView4TextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for a cogview4 image."""
+
+ prompt: str = InputField(description="Text prompt to encode.", ui_component=UIComponent.Textarea)
+ glm_encoder: GlmEncoderField = InputField(
+ title="GLM Encoder",
+ description=FieldDescriptions.glm_encoder,
+ input=Input.Connection,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> CogView4ConditioningOutput:
+ glm_embeds = self._glm_encode(context, max_seq_len=COGVIEW4_GLM_MAX_SEQ_LEN)
+ # Move embeddings to CPU for storage to save VRAM
+ glm_embeds = glm_embeds.detach().to("cpu")
+ conditioning_data = ConditioningFieldData(conditionings=[CogView4ConditioningInfo(glm_embeds=glm_embeds)])
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return CogView4ConditioningOutput.build(conditioning_name)
+
+ def _glm_encode(self, context: InvocationContext, max_seq_len: int) -> torch.Tensor:
+ prompt = [self.prompt]
+
+ # TODO(ryand): Add model inputs to the invocation rather than hard-coding.
+ glm_text_encoder_info = context.models.load(self.glm_encoder.text_encoder)
+ with (
+ glm_text_encoder_info.model_on_device() as (_, glm_text_encoder),
+ context.models.load(self.glm_encoder.tokenizer).model_on_device() as (_, glm_tokenizer),
+ ):
+ repaired_tensors = glm_text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(glm_text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required GLM tensor(s) onto {device} after a partial device mismatch."
+ )
+
+ context.util.signal_progress("Running GLM text encoder")
+ assert isinstance(glm_text_encoder, GlmModel)
+ assert isinstance(glm_tokenizer, PreTrainedTokenizerFast)
+
+ text_inputs = glm_tokenizer(
+ prompt,
+ padding="longest",
+ max_length=max_seq_len,
+ truncation=True,
+ add_special_tokens=True,
+ return_tensors="pt",
+ )
+ text_input_ids = text_inputs.input_ids
+ untruncated_ids = glm_tokenizer(prompt, padding="longest", return_tensors="pt").input_ids
+ assert isinstance(text_input_ids, torch.Tensor)
+ assert isinstance(untruncated_ids, torch.Tensor)
+ if untruncated_ids.shape[-1] >= text_input_ids.shape[-1] and not torch.equal(
+ text_input_ids, untruncated_ids
+ ):
+ removed_text = glm_tokenizer.batch_decode(untruncated_ids[:, max_seq_len - 1 : -1])
+ context.logger.warning(
+ "The following part of your input was truncated because `max_sequence_length` is set to "
+ f" {max_seq_len} tokens: {removed_text}"
+ )
+
+ current_length = text_input_ids.shape[1]
+ pad_length = (16 - (current_length % 16)) % 16
+ if pad_length > 0:
+ pad_ids = torch.full(
+ (text_input_ids.shape[0], pad_length),
+ fill_value=glm_tokenizer.pad_token_id,
+ dtype=text_input_ids.dtype,
+ device=text_input_ids.device,
+ )
+ text_input_ids = torch.cat([pad_ids, text_input_ids], dim=1)
+ prompt_embeds = glm_text_encoder(text_input_ids.to(device), output_hidden_states=True).hidden_states[-2]
+
+ assert isinstance(prompt_embeds, torch.Tensor)
+ return prompt_embeds
diff --git a/invokeai/app/invocations/collections.py b/invokeai/app/invocations/collections.py
index e02291980f9..39e77f5b637 100644
--- a/invokeai/app/invocations/collections.py
+++ b/invokeai/app/invocations/collections.py
@@ -4,17 +4,14 @@
import numpy as np
from pydantic import ValidationInfo, field_validator
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField
from invokeai.app.invocations.primitives import IntegerCollectionOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.misc import SEED_MAX
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField
-
-@invocation(
- "range", title="Integer Range", tags=["collection", "integer", "range"], category="collections", version="1.0.0"
-)
+@invocation("range", title="Integer Range", tags=["collection", "integer", "range"], category="batch", version="1.0.0")
class RangeInvocation(BaseInvocation):
"""Creates a range of numbers from start to stop with step"""
@@ -36,7 +33,7 @@ def invoke(self, context: InvocationContext) -> IntegerCollectionOutput:
"range_of_size",
title="Integer Range of Size",
tags=["collection", "integer", "size", "range"],
- category="collections",
+ category="batch",
version="1.0.0",
)
class RangeOfSizeInvocation(BaseInvocation):
@@ -56,7 +53,7 @@ def invoke(self, context: InvocationContext) -> IntegerCollectionOutput:
"random_range",
title="Random Range",
tags=["range", "integer", "random", "collection"],
- category="collections",
+ category="batch",
version="1.0.1",
use_cache=False,
)
diff --git a/invokeai/app/invocations/color_map.py b/invokeai/app/invocations/color_map.py
new file mode 100644
index 00000000000..ec95acfffd3
--- /dev/null
+++ b/invokeai/app/invocations/color_map.py
@@ -0,0 +1,41 @@
+import cv2
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.util import np_to_pil, pil_to_np
+
+
+@invocation(
+ "color_map",
+ title="Color Map",
+ tags=["controlnet"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class ColorMapInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates a color map from the provided image."""
+
+ image: ImageField = InputField(description="The image to process")
+ tile_size: int = InputField(default=64, ge=1, description=FieldDescriptions.tile_size)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+
+ np_image = pil_to_np(image)
+ height, width = np_image.shape[:2]
+
+ width_tile_size = min(self.tile_size, width)
+ height_tile_size = min(self.tile_size, height)
+
+ color_map = cv2.resize(
+ np_image,
+ (width // width_tile_size, height // height_tile_size),
+ interpolation=cv2.INTER_CUBIC,
+ )
+ color_map = cv2.resize(color_map, (width, height), interpolation=cv2.INTER_NEAREST)
+ color_map_pil = np_to_pil(color_map)
+
+ image_dto = context.images.save(image=color_map_pil)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/compel.py b/invokeai/app/invocations/compel.py
index 1e78e10d380..428f72d3964 100644
--- a/invokeai/app/invocations/compel.py
+++ b/invokeai/app/invocations/compel.py
@@ -1,10 +1,11 @@
from typing import Iterator, List, Optional, Tuple, Union, cast
import torch
-from compel import Compel, ReturnedEmbeddingsType
+from compel import Compel, ReturnedEmbeddingsType, SplitLongTextMode
from compel.prompt_parser import Blend, Conjunction, CrossAttentionControlSubstitute, FlattenedPrompt, Fragment
from transformers import CLIPTextModel, CLIPTextModelWithProjection, CLIPTokenizer
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.invocations.fields import (
ConditioningField,
FieldDescriptions,
@@ -14,11 +15,14 @@
TensorField,
UIComponent,
)
+from invokeai.app.invocations.model import CLIPField
from invokeai.app.invocations.primitives import ConditioningOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.ti_utils import generate_ti_list
-from invokeai.backend.lora import LoRAModelRaw
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
from invokeai.backend.model_patcher import ModelPatcher
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
BasicConditioningInfo,
ConditioningFieldData,
@@ -26,9 +30,6 @@
)
from invokeai.backend.util.devices import TorchDevice
-from .baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
-from .model import CLIPField
-
# unconditioned: Optional[torch.Tensor]
@@ -40,10 +41,11 @@
@invocation(
"compel",
- title="Prompt",
+ title="Prompt - SD1.5",
tags=["prompt", "compel"],
- category="conditioning",
- version="1.2.0",
+ category="prompt",
+ version="1.2.1",
+ idle_gpu_offloadable=True,
)
class CompelInvocation(BaseInvocation):
"""Parse prompt using compel package to conditioning."""
@@ -56,7 +58,6 @@ class CompelInvocation(BaseInvocation):
clip: CLIPField = InputField(
title="CLIP",
description=FieldDescriptions.clip,
- input=Input.Connection,
)
mask: Optional[TensorField] = InputField(
default=None, description="A mask defining the region that this conditioning prompt applies to."
@@ -64,29 +65,29 @@ class CompelInvocation(BaseInvocation):
@torch.no_grad()
def invoke(self, context: InvocationContext) -> ConditioningOutput:
- tokenizer_info = context.models.load(self.clip.tokenizer)
- text_encoder_info = context.models.load(self.clip.text_encoder)
-
- def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
+ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
for lora in self.clip.loras:
lora_info = context.models.load(lora.lora)
- assert isinstance(lora_info.model, LoRAModelRaw)
+ assert isinstance(lora_info.model, ModelPatchRaw)
yield (lora_info.model, lora.weight)
del lora_info
return
# loras = [(context.models.get(**lora.dict(exclude={"weight"})).context.model, lora.weight) for lora in self.clip.loras]
+ text_encoder_info = context.models.load(self.clip.text_encoder)
ti_list = generate_ti_list(self.prompt, text_encoder_info.config.base, context)
with (
# apply all patches while the model is on the target device
- text_encoder_info.model_on_device() as (model_state_dict, text_encoder),
- tokenizer_info as tokenizer,
- ModelPatcher.apply_lora_text_encoder(
- text_encoder,
- loras=_lora_loader(),
- model_state_dict=model_state_dict,
+ text_encoder_info.model_on_device() as (cached_weights, text_encoder),
+ context.models.load(self.clip.tokenizer) as tokenizer,
+ LayerPatcher.apply_smart_model_patches(
+ model=text_encoder,
+ patches=_lora_loader(),
+ prefix="lora_te_",
+ dtype=text_encoder.dtype,
+ cached_weights=cached_weights,
),
# Apply CLIP Skip after LoRA to prevent LoRA application from failing on skipped layers.
ModelPatcher.apply_clip_skip(text_encoder, self.clip.skipped_layers),
@@ -95,6 +96,7 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
ti_manager,
),
):
+ context.util.signal_progress("Building conditioning")
assert isinstance(text_encoder, CLIPTextModel)
assert isinstance(tokenizer, CLIPTokenizer)
compel = Compel(
@@ -103,6 +105,8 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
textual_inversion_manager=ti_manager,
dtype_for_device_getter=TorchDevice.choose_torch_dtype,
truncate_long_prompts=False,
+ device=get_effective_device(text_encoder),
+ split_long_text_mode=SplitLongTextMode.SENTENCES,
)
conjunction = Compel.parse_prompt_string(self.prompt)
@@ -112,6 +116,13 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
c, _options = compel.build_conditioning_tensor_for_conjunction(conjunction)
+ del compel
+ del patched_tokenizer
+ del tokenizer
+ del ti_manager
+ del text_encoder
+ del text_encoder_info
+
c = c.detach().to("cpu")
conditioning_data = ConditioningFieldData(conditionings=[BasicConditioningInfo(embeds=c)])
@@ -137,9 +148,7 @@ def run_clip_compel(
lora_prefix: str,
zero_on_empty: bool,
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
- tokenizer_info = context.models.load(clip_field.tokenizer)
text_encoder_info = context.models.load(clip_field.text_encoder)
-
# return zero on empty
if prompt == "" and zero_on_empty:
cpu_text_encoder = text_encoder_info.model
@@ -161,11 +170,11 @@ def run_clip_compel(
c_pooled = None
return c, c_pooled
- def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
+ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
for lora in clip_field.loras:
lora_info = context.models.load(lora.lora)
lora_model = lora_info.model
- assert isinstance(lora_model, LoRAModelRaw)
+ assert isinstance(lora_model, ModelPatchRaw)
yield (lora_model, lora.weight)
del lora_info
return
@@ -176,13 +185,14 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
with (
# apply all patches while the model is on the target device
- text_encoder_info.model_on_device() as (state_dict, text_encoder),
- tokenizer_info as tokenizer,
- ModelPatcher.apply_lora(
- text_encoder,
- loras=_lora_loader(),
+ text_encoder_info.model_on_device() as (cached_weights, text_encoder),
+ context.models.load(clip_field.tokenizer) as tokenizer,
+ LayerPatcher.apply_smart_model_patches(
+ model=text_encoder,
+ patches=_lora_loader(),
prefix=lora_prefix,
- model_state_dict=state_dict,
+ dtype=text_encoder.dtype,
+ cached_weights=cached_weights,
),
# Apply CLIP Skip after LoRA to prevent LoRA application from failing on skipped layers.
ModelPatcher.apply_clip_skip(text_encoder, clip_field.skipped_layers),
@@ -191,6 +201,7 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
ti_manager,
),
):
+ context.util.signal_progress("Building conditioning")
assert isinstance(text_encoder, (CLIPTextModel, CLIPTextModelWithProjection))
assert isinstance(tokenizer, CLIPTokenizer)
@@ -203,6 +214,8 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
truncate_long_prompts=False, # TODO:
returned_embeddings_type=ReturnedEmbeddingsType.PENULTIMATE_HIDDEN_STATES_NON_NORMALIZED, # TODO: clip skip
requires_pooled=get_pooled,
+ device=get_effective_device(text_encoder),
+ split_long_text_mode=SplitLongTextMode.SENTENCES,
)
conjunction = Compel.parse_prompt_string(prompt)
@@ -218,9 +231,11 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
else:
c_pooled = None
+ del compel
+ del patched_tokenizer
del tokenizer
+ del ti_manager
del text_encoder
- del tokenizer_info
del text_encoder_info
c = c.detach().to("cpu")
@@ -232,10 +247,11 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
@invocation(
"sdxl_compel_prompt",
- title="SDXL Prompt",
+ title="Prompt - SDXL",
tags=["sdxl", "compel", "prompt"],
- category="conditioning",
- version="1.2.0",
+ category="prompt",
+ version="1.2.1",
+ idle_gpu_offloadable=True,
)
class SDXLCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase):
"""Parse prompt using compel package to conditioning."""
@@ -326,10 +342,11 @@ def invoke(self, context: InvocationContext) -> ConditioningOutput:
@invocation(
"sdxl_refiner_compel_prompt",
- title="SDXL Refiner Prompt",
+ title="Prompt - SDXL Refiner",
tags=["sdxl", "compel", "prompt"],
- category="conditioning",
- version="1.1.1",
+ category="prompt",
+ version="1.1.2",
+ idle_gpu_offloadable=True,
)
class SDXLRefinerCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase):
"""Parse prompt using compel package to conditioning."""
@@ -375,10 +392,10 @@ class CLIPSkipInvocationOutput(BaseInvocationOutput):
@invocation(
"clip_skip",
- title="CLIP Skip",
+ title="Apply CLIP Skip - SD1.5, SDXL",
tags=["clipskip", "clip", "skip"],
- category="conditioning",
- version="1.1.0",
+ category="prompt",
+ version="1.1.1",
)
class CLIPSkipInvocation(BaseInvocation):
"""Skip layers in clip text_encoder model."""
@@ -512,7 +529,7 @@ def log_tokenization_for_text(
usedTokens += 1
if usedTokens > 0:
- print(f'\n>> [TOKENLOG] Tokens {display_label or ""} ({usedTokens}):')
+ print(f"\n>> [TOKENLOG] Tokens {display_label or ''} ({usedTokens}):")
print(f"{tokenized}\x1b[0m")
if discarded != "":
diff --git a/invokeai/app/invocations/composition-nodes.py b/invokeai/app/invocations/composition-nodes.py
new file mode 100644
index 00000000000..babbf29151a
--- /dev/null
+++ b/invokeai/app/invocations/composition-nodes.py
@@ -0,0 +1,1545 @@
+# All nodes in this file are originally pulled from https://github.com/dwringer/composition-nodes
+
+import os
+from ast import literal_eval as tuple_from_string
+from functools import reduce
+from io import BytesIO
+from math import pi as PI
+from typing import Literal, Optional
+
+import cv2
+import numpy
+import torch
+from PIL import Image, ImageChops, ImageCms, ImageColor, ImageDraw, ImageEnhance, ImageOps
+from torchvision.transforms.functional import to_pil_image as pil_image_from_tensor
+
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.backend.image_util.color_conversion import (
+ hsl_from_srgb,
+ linear_srgb_from_oklab,
+ linear_srgb_from_oklch,
+ linear_srgb_from_srgb,
+ okhsl_from_srgb,
+ okhsv_from_srgb,
+ oklab_from_linear_srgb,
+ oklab_from_oklch,
+ oklch_from_oklab,
+ srgb_from_hsl,
+ srgb_from_okhsl,
+ srgb_from_okhsv,
+)
+from invokeai.backend.image_util.composition import (
+ CIELAB_TO_UPLAB_ICC_PATH,
+ MAX_FLOAT,
+ equivalent_achromatic_lightness,
+ gamut_clip_tensor,
+ remove_nans,
+ srgb_from_linear_srgb,
+ tensor_from_pil_image,
+)
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.invocation_api import (
+ BaseInvocation,
+ ImageField,
+ InputField,
+ InvocationContext,
+ WithBoard,
+ WithMetadata,
+ invocation,
+)
+
+HUE_COLOR_SPACES = Literal[
+ "HSV / HSL / RGB",
+ "Okhsl",
+ "Okhsv",
+ "*Oklch / Oklab",
+ "*LCh / CIELab",
+ "*UPLab (w/CIELab_to_UPLab.icc)",
+]
+
+
+@invocation(
+ "invokeai_img_hue_adjust_plus",
+ title="Adjust Image Hue Plus",
+ tags=["image", "hue", "oklab", "cielab", "uplab", "lch", "hsv", "hsl", "lab"],
+ category="image",
+ version="1.2.0",
+)
+class InvokeAdjustImageHuePlusInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Adjusts the Hue of an image by rotating it in the selected color space. Originally created by @dwringer"""
+
+ image: ImageField = InputField(description="The image to adjust")
+ space: HUE_COLOR_SPACES = InputField(
+ default="HSV / HSL / RGB",
+ description="Color space in which to rotate hue by polar coords (*: non-invertible)",
+ )
+ degrees: float = InputField(default=0.0, description="Degrees by which to rotate image hue")
+ preserve_lightness: bool = InputField(default=False, description="Whether to preserve CIELAB lightness values")
+ ok_adaptive_gamut: float = InputField(
+ ge=0, default=0.05, description="Higher preserves chroma at the expense of lightness (Oklab)"
+ )
+ ok_high_precision: bool = InputField(
+ default=True, description="Use more steps in computing gamut (Oklab/Okhsv/Okhsl)"
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_in = context.images.get_pil(self.image.image_name)
+ image_out = None
+ space = self.space.split()[0].lower().strip("*")
+
+ # Keep the mode and alpha channel for restoration after shifting the hue:
+ image_mode = image_in.mode
+ original_mode = image_mode
+ alpha_channel = None
+ if (image_mode == "RGBA") or (image_mode == "LA") or (image_mode == "PA"):
+ alpha_channel = image_in.getchannel("A")
+ elif (image_mode == "RGBa") or (image_mode == "La") or (image_mode == "Pa"):
+ alpha_channel = image_in.getchannel("a")
+ if (image_mode == "RGBA") or (image_mode == "RGBa"):
+ image_mode = "RGB"
+ elif (image_mode == "LA") or (image_mode == "La"):
+ image_mode = "L"
+ elif image_mode == "PA":
+ image_mode = "P"
+
+ image_in = image_in.convert("RGB")
+
+ # Keep the CIELAB L* lightness channel for restoration if Preserve Lightness is selected:
+ (channel_l, channel_a, channel_b, profile_srgb, profile_lab, profile_uplab, lab_transform, uplab_transform) = (
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ if self.preserve_lightness or (space == "lch") or (space == "uplab"):
+ profile_srgb = ImageCms.createProfile("sRGB")
+ if space == "uplab":
+ with open(CIELAB_TO_UPLAB_ICC_PATH, "rb") as f:
+ profile_uplab = ImageCms.getOpenProfile(f)
+ if profile_uplab is None:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=6500)
+ else:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=5000)
+
+ lab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_srgb, profile_lab, "RGB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_in, lab_transform)
+ if profile_uplab is not None:
+ uplab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_uplab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, uplab_transform)
+
+ channel_l = image_out.getchannel("L")
+ channel_a = image_out.getchannel("A")
+ channel_b = image_out.getchannel("B")
+
+ if space == "hsv":
+ hsv_tensor = image_resized_to_grid_as_tensor(image_in.convert("HSV"), normalize=False, multiple_of=1)
+ hsv_tensor[0, :, :] = torch.remainder(torch.add(hsv_tensor[0, :, :] * 360.0, self.degrees), 360.0) / 360.0
+ image_out = pil_image_from_tensor(hsv_tensor, mode="HSV").convert("RGB")
+
+ elif space == "okhsl":
+ rgb_tensor = image_resized_to_grid_as_tensor(image_in.convert("RGB"), normalize=False, multiple_of=1)
+ hsl_tensor = okhsl_from_srgb(rgb_tensor, steps=(3 if self.ok_high_precision else 1))
+ hsl_tensor[0, :, :] = torch.remainder(torch.add(hsl_tensor[0, :, :], self.degrees), 360.0)
+ rgb_tensor = srgb_from_okhsl(hsl_tensor, alpha=0.0)
+ image_out = pil_image_from_tensor(rgb_tensor, mode="RGB")
+
+ elif space == "okhsv":
+ rgb_tensor = image_resized_to_grid_as_tensor(image_in.convert("RGB"), normalize=False, multiple_of=1)
+ hsv_tensor = okhsv_from_srgb(rgb_tensor, steps=(3 if self.ok_high_precision else 1))
+ hsv_tensor[0, :, :] = torch.remainder(torch.add(hsv_tensor[0, :, :], self.degrees), 360.0)
+ rgb_tensor = srgb_from_okhsv(hsv_tensor, alpha=0.0)
+ image_out = pil_image_from_tensor(rgb_tensor, mode="RGB")
+
+ elif (space == "lch") or (space == "uplab"):
+ #
+
+ a_tensor = image_resized_to_grid_as_tensor(channel_a, normalize=True, multiple_of=1)
+ b_tensor = image_resized_to_grid_as_tensor(channel_b, normalize=True, multiple_of=1)
+
+ # L*a*b* to L*C*h
+ c_tensor = torch.sqrt(torch.add(torch.pow(a_tensor, 2.0), torch.pow(b_tensor, 2.0)))
+ h_tensor = torch.atan2(b_tensor, a_tensor)
+
+ # Rotate h
+ rot_rads = (self.degrees / 180.0) * PI
+
+ h_rot = torch.add(h_tensor, rot_rads)
+ h_rot = torch.sub(torch.remainder(torch.add(h_rot, PI), 2 * PI), PI)
+
+ # L*C*h to L*a*b*
+ a_tensor = torch.mul(c_tensor, torch.cos(h_rot))
+ b_tensor = torch.mul(c_tensor, torch.sin(h_rot))
+
+ # -1..1 -> 0..1 for all elts of a, b
+ a_tensor = torch.div(torch.add(a_tensor, 1.0), 2.0)
+ b_tensor = torch.div(torch.add(b_tensor, 1.0), 2.0)
+
+ a_img = pil_image_from_tensor(a_tensor)
+ b_img = pil_image_from_tensor(b_tensor)
+
+ image_out = Image.merge("LAB", (channel_l, a_img, b_img))
+
+ if profile_uplab is not None:
+ deuplab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_uplab, profile_lab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, deuplab_transform)
+
+ rgb_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_srgb, "LAB", "RGB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, rgb_transform)
+
+ elif space == "oklch":
+ rgb_tensor = image_resized_to_grid_as_tensor(image_in.convert("RGB"), normalize=False, multiple_of=1)
+
+ linear_srgb_tensor = linear_srgb_from_srgb(rgb_tensor)
+ oklch_tensor = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_tensor))
+ oklch_tensor[2, :, :] = torch.remainder(torch.add(oklch_tensor[2, :, :], self.degrees), 360.0)
+ linear_srgb_tensor = linear_srgb_from_oklch(oklch_tensor)
+
+ rgb_tensor = srgb_from_linear_srgb(
+ linear_srgb_tensor, alpha=self.ok_adaptive_gamut, steps=(3 if self.ok_high_precision else 1)
+ )
+
+ image_out = pil_image_from_tensor(rgb_tensor, mode="RGB")
+
+ # Not all modes can convert directly to LAB using pillow:
+ # image_out = image_out.convert("RGB")
+
+ # Restore the L* channel if required:
+ if self.preserve_lightness and (not ((space == "lch") or (space == "uplab"))):
+ if profile_uplab is None:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=6500)
+ else:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=5000)
+
+ lab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_srgb, profile_lab, "RGB", "LAB", renderingIntent=2, flags=0x2400
+ )
+
+ image_out = ImageCms.applyTransform(image_out, lab_transform)
+
+ if profile_uplab is not None:
+ uplab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_uplab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, uplab_transform)
+
+ image_out = Image.merge("LAB", tuple([channel_l] + [image_out.getchannel(c) for c in "AB"]))
+
+ if profile_uplab is not None:
+ deuplab_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_uplab, profile_lab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, deuplab_transform)
+
+ rgb_transform = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_srgb, "LAB", "RGB", renderingIntent=2, flags=0x2400
+ )
+ image_out = ImageCms.applyTransform(image_out, rgb_transform)
+
+ # Restore the original image mode, with alpha channel if required:
+ image_out = image_out.convert(image_mode)
+ if "a" in original_mode.lower():
+ image_out = Image.merge(
+ original_mode, tuple([image_out.getchannel(c) for c in image_mode] + [alpha_channel])
+ )
+
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "invokeai_img_enhance",
+ title="Enhance Image",
+ tags=["enhance", "image"],
+ category="image",
+ version="1.2.1",
+)
+class InvokeImageEnhanceInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Applies processing from PIL's ImageEnhance module. Originally created by @dwringer"""
+
+ image: ImageField = InputField(description="The image for which to apply processing")
+ invert: bool = InputField(default=False, description="Whether to invert the image colors")
+ color: float = InputField(ge=0, default=1.0, description="Color enhancement factor")
+ contrast: float = InputField(ge=0, default=1.0, description="Contrast enhancement factor")
+ brightness: float = InputField(ge=0, default=1.0, description="Brightness enhancement factor")
+ sharpness: float = InputField(ge=0, default=1.0, description="Sharpness enhancement factor")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_out = context.images.get_pil(self.image.image_name)
+ if self.invert:
+ if image_out.mode not in ("L", "RGB"):
+ image_out = image_out.convert("RGB")
+ image_out = ImageOps.invert(image_out)
+ if self.color != 1.0:
+ color_enhancer = ImageEnhance.Color(image_out)
+ image_out = color_enhancer.enhance(self.color)
+ if self.contrast != 1.0:
+ contrast_enhancer = ImageEnhance.Contrast(image_out)
+ image_out = contrast_enhancer.enhance(self.contrast)
+ if self.brightness != 1.0:
+ brightness_enhancer = ImageEnhance.Brightness(image_out)
+ image_out = brightness_enhancer.enhance(self.brightness)
+ if self.sharpness != 1.0:
+ sharpness_enhancer = ImageEnhance.Sharpness(image_out)
+ image_out = sharpness_enhancer.enhance(self.sharpness)
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "invokeai_ealightness",
+ title="Equivalent Achromatic Lightness",
+ tags=["image", "channel", "mask", "cielab", "lab"],
+ category="image",
+ version="1.2.0",
+)
+class InvokeEquivalentAchromaticLightnessInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Calculate Equivalent Achromatic Lightness from image. Originally created by @dwringer"""
+
+ image: ImageField = InputField(description="Image from which to get channel")
+
+ # The chroma, C*
+ # , and the hue, h, in the CIELAB color space are obtained by C*=sqrt((a*)^2+(b*)^2)
+ # and h=arctan(b*/a*)
+ # k 0.1644 0.0603 0.1307 0.0060
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_in = context.images.get_pil(self.image.image_name)
+
+ if image_in.mode == "L":
+ image_in = image_in.convert("RGB")
+
+ image_out = image_in.convert("LAB")
+ channel_l = image_out.getchannel("L")
+ channel_a = image_out.getchannel("A")
+ channel_b = image_out.getchannel("B")
+
+ l_tensor = image_resized_to_grid_as_tensor(channel_l, normalize=False, multiple_of=1)
+ l_max = torch.ones(l_tensor.shape)
+ l_min = torch.zeros(l_tensor.shape)
+ a_tensor = image_resized_to_grid_as_tensor(channel_a, normalize=True, multiple_of=1)
+ b_tensor = image_resized_to_grid_as_tensor(channel_b, normalize=True, multiple_of=1)
+
+ c_tensor = torch.sqrt(torch.add(torch.pow(a_tensor, 2.0), torch.pow(b_tensor, 2.0)))
+ h_tensor = torch.atan2(b_tensor, a_tensor)
+
+ k = [0.1644, 0.0603, 0.1307, 0.0060]
+
+ h_minus_90 = torch.sub(h_tensor, PI / 2.0)
+ h_minus_90 = torch.sub(torch.remainder(torch.add(h_minus_90, 3 * PI), 2 * PI), PI)
+
+ f_by = torch.add(k[0] * torch.abs(torch.sin(torch.div(h_minus_90, 2.0))), k[1])
+ f_r_0 = torch.add(k[2] * torch.abs(torch.cos(h_tensor)), k[3])
+
+ f_r = torch.zeros(l_tensor.shape)
+ mask_hi = torch.ge(h_tensor, -1 * (PI / 2.0))
+ mask_lo = torch.le(h_tensor, PI / 2.0)
+ mask = torch.logical_and(mask_hi, mask_lo)
+ f_r[mask] = f_r_0[mask]
+
+ l_adjustment = torch.tensordot(torch.add(f_by, f_r), c_tensor, dims=([1, 2], [1, 2]))
+ l_max = torch.add(l_max, l_adjustment)
+ l_min = torch.add(l_min, l_adjustment)
+ image_tensor = torch.add(l_tensor, l_adjustment)
+
+ image_tensor = torch.div(torch.sub(image_tensor, l_min.min()), l_max.max() - l_min.min())
+
+ image_out = pil_image_from_tensor(image_tensor)
+
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
+
+
+BLEND_MODES = Literal[
+ "Normal",
+ "Lighten Only",
+ "Darken Only",
+ "Lighten Only (EAL)",
+ "Darken Only (EAL)",
+ "Hue",
+ "Saturation",
+ "Color",
+ "Luminosity",
+ "Linear Dodge (Add)",
+ "Subtract",
+ "Multiply",
+ "Divide",
+ "Screen",
+ "Overlay",
+ "Linear Burn",
+ "Difference",
+ "Hard Light",
+ "Soft Light",
+ "Vivid Light",
+ "Linear Light",
+ "Color Burn",
+ "Color Dodge",
+]
+
+BLEND_COLOR_SPACES = Literal[
+ "RGB", "Linear RGB", "HSL (RGB)", "HSV (RGB)", "Okhsl", "Okhsv", "Oklch (Oklab)", "LCh (CIELab)"
+]
+
+
+@invocation(
+ "invokeai_img_blend",
+ title="Image Layer Blend",
+ tags=["image", "blend", "layer", "alpha", "composite", "dodge", "burn"],
+ category="image",
+ version="1.2.0",
+)
+class InvokeImageBlendInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Blend two images together, with optional opacity, mask, and blend modes. Originally created by @dwringer"""
+
+ layer_upper: ImageField = InputField(description="The top image to blend", ui_order=1)
+ blend_mode: BLEND_MODES = InputField(default="Normal", description="Available blend modes", ui_order=2)
+ opacity: float = InputField(ge=0, default=1.0, description="Desired opacity of the upper layer", ui_order=3)
+ mask: Optional[ImageField] = InputField(
+ default=None, description="Optional mask, used to restrict areas from blending", ui_order=4
+ )
+ fit_to_width: bool = InputField(default=False, description="Scale upper layer to fit base width", ui_order=5)
+ fit_to_height: bool = InputField(default=True, description="Scale upper layer to fit base height", ui_order=6)
+ layer_base: ImageField = InputField(description="The bottom image to blend", ui_order=7)
+ color_space: BLEND_COLOR_SPACES = InputField(
+ default="RGB", description="Available color spaces for blend computations", ui_order=8
+ )
+ adaptive_gamut: float = InputField(
+ ge=0,
+ default=0.0,
+ description="Adaptive gamut clipping (0=off). Higher prioritizes chroma over lightness",
+ ui_order=9,
+ )
+ high_precision: bool = InputField(
+ default=True, description="Use more steps in computing gamut when possible", ui_order=10
+ )
+
+ def scale_and_pad_or_crop_to_base(self, image_upper: Image.Image, image_base: Image.Image):
+ """Rescale upper image based on self.fill_x and self.fill_y params"""
+
+ aspect_base = image_base.width / image_base.height
+ aspect_upper = image_upper.width / image_upper.height
+ if self.fit_to_width and self.fit_to_height:
+ image_upper = image_upper.resize((image_base.width, image_base.height))
+ elif (self.fit_to_width and (aspect_base < aspect_upper)) or (
+ self.fit_to_height and (aspect_upper <= aspect_base)
+ ):
+ image_upper = ImageOps.pad(
+ image_upper, (image_base.width, image_base.height), color=tuple([0 for band in image_upper.getbands()])
+ )
+ elif (self.fit_to_width and (aspect_upper <= aspect_base)) or (
+ self.fit_to_height and (aspect_base < aspect_upper)
+ ):
+ image_upper = ImageOps.fit(image_upper, (image_base.width, image_base.height))
+ return image_upper
+
+ def image_convert_with_xform(self, image_in: Image.Image, from_mode: str, to_mode: str):
+ """Use PIL ImageCms color management to convert 3-channel image from one mode to another"""
+
+ def fixed_mode(mode: str):
+ if mode.lower() == "srgb":
+ return "rgb"
+ elif mode.lower() == "cielab":
+ return "lab"
+ else:
+ return mode.lower()
+
+ from_mode, to_mode = fixed_mode(from_mode), fixed_mode(to_mode)
+
+ profile_srgb = None
+ profile_uplab = None
+ profile_lab = None
+ if (from_mode.lower() == "rgb") or (to_mode.lower() == "rgb"):
+ profile_srgb = ImageCms.createProfile("sRGB")
+ if (from_mode.lower() == "uplab") or (to_mode.lower() == "uplab"):
+ if os.path.isfile("CIELab_to_UPLab.icc"):
+ profile_uplab = ImageCms.getOpenProfile("CIELab_to_UPLab.icc")
+ if (from_mode.lower() in ["lab", "cielab", "uplab"]) or (to_mode.lower() in ["lab", "cielab", "uplab"]):
+ if profile_uplab is None:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=6500)
+ else:
+ profile_lab = ImageCms.createProfile("LAB", colorTemp=5000)
+
+ xform_rgb_to_lab = None
+ xform_uplab_to_lab = None
+ xform_lab_to_uplab = None
+ xform_lab_to_rgb = None
+ if from_mode == "rgb":
+ xform_rgb_to_lab = ImageCms.buildTransformFromOpenProfiles(
+ profile_srgb, profile_lab, "RGB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ elif from_mode == "uplab":
+ xform_uplab_to_lab = ImageCms.buildTransformFromOpenProfiles(
+ profile_uplab, profile_lab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ if to_mode == "uplab":
+ xform_lab_to_uplab = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_uplab, "LAB", "LAB", renderingIntent=2, flags=0x2400
+ )
+ elif to_mode == "rgb":
+ xform_lab_to_rgb = ImageCms.buildTransformFromOpenProfiles(
+ profile_lab, profile_srgb, "LAB", "RGB", renderingIntent=2, flags=0x2400
+ )
+
+ image_out = None
+ if (from_mode == "rgb") and (to_mode == "lab"):
+ image_out = ImageCms.applyTransform(image_in, xform_rgb_to_lab)
+ elif (from_mode == "rgb") and (to_mode == "uplab"):
+ image_out = ImageCms.applyTransform(image_in, xform_rgb_to_lab)
+ image_out = ImageCms.applyTransform(image_out, xform_lab_to_uplab)
+ elif (from_mode == "lab") and (to_mode == "uplab"):
+ image_out = ImageCms.applyTransform(image_in, xform_lab_to_uplab)
+ elif (from_mode == "lab") and (to_mode == "rgb"):
+ image_out = ImageCms.applyTransform(image_in, xform_lab_to_rgb)
+ elif (from_mode == "uplab") and (to_mode == "lab"):
+ image_out = ImageCms.applyTransform(image_in, xform_uplab_to_lab)
+ elif (from_mode == "uplab") and (to_mode == "rgb"):
+ image_out = ImageCms.applyTransform(image_in, xform_uplab_to_lab)
+ image_out = ImageCms.applyTransform(image_out, xform_lab_to_rgb)
+
+ return image_out
+
+ def prepare_tensors_from_images(
+ self,
+ image_upper: Image.Image,
+ image_lower: Image.Image,
+ mask_image: Optional[Image.Image] = None,
+ required: Optional[list[str]] = None,
+ ):
+ """Convert image to the necessary image space representations for blend calculations"""
+ required = required or ["hsv", "hsl", "lch", "oklch", "okhsl", "okhsv", "l_eal"]
+ alpha_upper, alpha_lower = None, None
+ if image_upper.mode == "RGBA":
+ # Prepare tensors to compute blend
+ image_rgba_upper = image_upper.convert("RGBA")
+ alpha_upper = image_rgba_upper.getchannel("A")
+ image_upper = image_upper.convert("RGB")
+ else:
+ if not (image_upper.mode == "RGB"):
+ image_upper = image_upper.convert("RGB")
+ if image_lower.mode == "RGBA":
+ # Prepare tensors to compute blend
+ image_rgba_lower = image_lower.convert("RGBA")
+ alpha_lower = image_rgba_lower.getchannel("A")
+ image_lower = image_lower.convert("RGB")
+ else:
+ if not (image_lower.mode == "RGB"):
+ image_lower = image_lower.convert("RGB")
+
+ image_lab_upper, image_lab_lower = None, None
+ upper_lab_tensor, lower_lab_tensor = None, None
+ upper_lch_tensor, lower_lch_tensor = None, None
+ if "lch" in required:
+ image_lab_upper, image_lab_lower = (
+ self.image_convert_with_xform(image_upper, "rgb", "lab"),
+ self.image_convert_with_xform(image_lower, "rgb", "lab"),
+ )
+
+ upper_lab_tensor = torch.stack(
+ [
+ tensor_from_pil_image(image_lab_upper.getchannel("L"), normalize=False)[0, :, :],
+ tensor_from_pil_image(image_lab_upper.getchannel("A"), normalize=True)[0, :, :],
+ tensor_from_pil_image(image_lab_upper.getchannel("B"), normalize=True)[0, :, :],
+ ]
+ )
+ lower_lab_tensor = torch.stack(
+ [
+ tensor_from_pil_image(image_lab_lower.getchannel("L"), normalize=False)[0, :, :],
+ tensor_from_pil_image(image_lab_lower.getchannel("A"), normalize=True)[0, :, :],
+ tensor_from_pil_image(image_lab_lower.getchannel("B"), normalize=True)[0, :, :],
+ ]
+ )
+ upper_lch_tensor = torch.stack(
+ [
+ upper_lab_tensor[0, :, :],
+ torch.sqrt(
+ torch.add(torch.pow(upper_lab_tensor[1, :, :], 2.0), torch.pow(upper_lab_tensor[2, :, :], 2.0))
+ ),
+ torch.atan2(upper_lab_tensor[2, :, :], upper_lab_tensor[1, :, :]),
+ ]
+ )
+ lower_lch_tensor = torch.stack(
+ [
+ lower_lab_tensor[0, :, :],
+ torch.sqrt(
+ torch.add(torch.pow(lower_lab_tensor[1, :, :], 2.0), torch.pow(lower_lab_tensor[2, :, :], 2.0))
+ ),
+ torch.atan2(lower_lab_tensor[2, :, :], lower_lab_tensor[1, :, :]),
+ ]
+ )
+
+ upper_l_eal_tensor, lower_l_eal_tensor = None, None
+ if "l_eal" in required:
+ upper_l_eal_tensor = equivalent_achromatic_lightness(upper_lch_tensor)
+ lower_l_eal_tensor = equivalent_achromatic_lightness(lower_lch_tensor)
+
+ image_hsv_upper, image_hsv_lower = None, None
+ upper_hsv_tensor, lower_hsv_tensor = None, None
+ if "hsv" in required:
+ image_hsv_upper, image_hsv_lower = image_upper.convert("HSV"), image_lower.convert("HSV")
+ upper_hsv_tensor = torch.stack(
+ [
+ tensor_from_pil_image(image_hsv_upper.getchannel("H"), normalize=False)[0, :, :] * 360.0,
+ tensor_from_pil_image(image_hsv_upper.getchannel("S"), normalize=False)[0, :, :],
+ tensor_from_pil_image(image_hsv_upper.getchannel("V"), normalize=False)[0, :, :],
+ ]
+ )
+ lower_hsv_tensor = torch.stack(
+ [
+ tensor_from_pil_image(image_hsv_lower.getchannel("H"), normalize=False)[0, :, :] * 360.0,
+ tensor_from_pil_image(image_hsv_lower.getchannel("S"), normalize=False)[0, :, :],
+ tensor_from_pil_image(image_hsv_lower.getchannel("V"), normalize=False)[0, :, :],
+ ]
+ )
+
+ upper_rgb_tensor = tensor_from_pil_image(image_upper, normalize=False)
+ lower_rgb_tensor = tensor_from_pil_image(image_lower, normalize=False)
+
+ alpha_upper_tensor, alpha_lower_tensor = None, None
+ if alpha_upper is None:
+ alpha_upper_tensor = torch.ones(upper_rgb_tensor[0, :, :].shape)
+ else:
+ alpha_upper_tensor = tensor_from_pil_image(alpha_upper, normalize=False)[0, :, :]
+ if alpha_lower is None:
+ alpha_lower_tensor = torch.ones(lower_rgb_tensor[0, :, :].shape)
+ else:
+ alpha_lower_tensor = tensor_from_pil_image(alpha_lower, normalize=False)[0, :, :]
+
+ mask_tensor = None
+ if mask_image is not None:
+ mask_tensor = tensor_from_pil_image(mask_image.convert("L"), normalize=False)[0, :, :]
+
+ upper_hsl_tensor, lower_hsl_tensor = None, None
+ if "hsl" in required:
+ upper_hsl_tensor = hsl_from_srgb(upper_rgb_tensor)
+ lower_hsl_tensor = hsl_from_srgb(lower_rgb_tensor)
+
+ upper_okhsl_tensor, lower_okhsl_tensor = None, None
+ if "okhsl" in required:
+ upper_okhsl_tensor = okhsl_from_srgb(upper_rgb_tensor, steps=(3 if self.high_precision else 1))
+ lower_okhsl_tensor = okhsl_from_srgb(lower_rgb_tensor, steps=(3 if self.high_precision else 1))
+
+ upper_okhsv_tensor, lower_okhsv_tensor = None, None
+ if "okhsv" in required:
+ upper_okhsv_tensor = okhsv_from_srgb(upper_rgb_tensor, steps=(3 if self.high_precision else 1))
+ lower_okhsv_tensor = okhsv_from_srgb(lower_rgb_tensor, steps=(3 if self.high_precision else 1))
+
+ upper_rgb_l_tensor = linear_srgb_from_srgb(upper_rgb_tensor)
+ lower_rgb_l_tensor = linear_srgb_from_srgb(lower_rgb_tensor)
+
+ upper_oklab_tensor, lower_oklab_tensor = None, None
+ upper_oklch_tensor, lower_oklch_tensor = None, None
+ if "oklch" in required:
+ upper_oklab_tensor = oklab_from_linear_srgb(upper_rgb_l_tensor)
+ lower_oklab_tensor = oklab_from_linear_srgb(lower_rgb_l_tensor)
+ upper_oklch_tensor = oklch_from_oklab(upper_oklab_tensor)
+ lower_oklch_tensor = oklch_from_oklab(lower_oklab_tensor)
+
+ return (
+ upper_rgb_l_tensor,
+ lower_rgb_l_tensor,
+ upper_rgb_tensor,
+ lower_rgb_tensor,
+ alpha_upper_tensor,
+ alpha_lower_tensor,
+ mask_tensor,
+ upper_hsv_tensor,
+ lower_hsv_tensor,
+ upper_hsl_tensor,
+ lower_hsl_tensor,
+ upper_lab_tensor,
+ lower_lab_tensor,
+ upper_lch_tensor,
+ lower_lch_tensor,
+ upper_l_eal_tensor,
+ lower_l_eal_tensor,
+ upper_oklab_tensor,
+ lower_oklab_tensor,
+ upper_oklch_tensor,
+ lower_oklch_tensor,
+ upper_okhsv_tensor,
+ lower_okhsv_tensor,
+ upper_okhsl_tensor,
+ lower_okhsl_tensor,
+ )
+
+ def apply_blend(self, image_tensors: torch.Tensor):
+ """Apply the selected blend mode using the appropriate color space representations"""
+
+ blend_mode = self.blend_mode
+ color_space = self.color_space.split()[0]
+ if (color_space in ["RGB", "Linear"]) and (blend_mode in ["Hue", "Saturation", "Luminosity", "Color"]):
+ color_space = "HSL"
+
+ def adaptive_clipped(rgb_tensor: torch.Tensor, clamp: bool = True, replace_with: float = MAX_FLOAT):
+ """Keep elements of the tensor finite"""
+
+ rgb_tensor = remove_nans(rgb_tensor, replace_with=replace_with)
+
+ if 0 < self.adaptive_gamut:
+ rgb_tensor = gamut_clip_tensor(
+ rgb_tensor, alpha=self.adaptive_gamut, steps=(3 if self.high_precision else 1)
+ )
+ rgb_tensor = remove_nans(rgb_tensor, replace_with=replace_with)
+ if clamp: # Use of MAX_FLOAT seems to lead to NaN's coming back in some cases:
+ rgb_tensor = rgb_tensor.clamp(0.0, 1.0)
+
+ return rgb_tensor
+
+ reassembly_function = {
+ "RGB": lambda t: linear_srgb_from_srgb(t),
+ "Linear": lambda t: t,
+ "HSL": lambda t: linear_srgb_from_srgb(srgb_from_hsl(t)),
+ "HSV": lambda t: linear_srgb_from_srgb(
+ tensor_from_pil_image(
+ pil_image_from_tensor(
+ torch.stack(
+ [
+ torch.remainder(t[0, :, :], 360.0) / 360.0,
+ t[1, :, :].clamp(0.0, 1.0),
+ t[2, :, :].clamp(0.0, 1.0),
+ ]
+ ),
+ mode="HSV",
+ ).convert("RGB"),
+ normalize=False,
+ )
+ ),
+ "Okhsl": lambda t: linear_srgb_from_srgb(
+ srgb_from_okhsl(t, alpha=self.adaptive_gamut, steps=(3 if self.high_precision else 1))
+ ),
+ "Okhsv": lambda t: linear_srgb_from_srgb(
+ srgb_from_okhsv(t, alpha=self.adaptive_gamut, steps=(3 if self.high_precision else 1))
+ ),
+ "Oklch": lambda t: linear_srgb_from_oklab(oklab_from_oklch(t)),
+ "LCh": lambda t: linear_srgb_from_srgb(
+ tensor_from_pil_image(
+ self.image_convert_with_xform(
+ Image.merge(
+ "LAB",
+ tuple(
+ pil_image_from_tensor(u)
+ for u in [
+ t[0, :, :].clamp(0.0, 1.0),
+ torch.div(torch.add(torch.mul(t[1, :, :], torch.cos(t[2, :, :])), 1.0), 2.0),
+ torch.div(torch.add(torch.mul(t[1, :, :], torch.sin(t[2, :, :])), 1.0), 2.0),
+ ]
+ ),
+ ),
+ "lab",
+ "rgb",
+ ),
+ normalize=False,
+ )
+ ),
+ }[color_space]
+
+ (
+ upper_rgb_l_tensor, # linear-light sRGB
+ lower_rgb_l_tensor, # linear-light sRGB
+ upper_rgb_tensor,
+ lower_rgb_tensor,
+ alpha_upper_tensor,
+ alpha_lower_tensor,
+ mask_tensor,
+ upper_hsv_tensor, # h_hsv_degrees, s_hsv, v_hsv
+ lower_hsv_tensor,
+ upper_hsl_tensor, # h_hsl_degrees, s_hsl, l_hsl
+ lower_hsl_tensor,
+ upper_lab_tensor, # l_lab, a_lab, b_lab
+ lower_lab_tensor,
+ upper_lch_tensor, # , c_lab, h_lab
+ lower_lch_tensor,
+ upper_l_eal_tensor, # l_eal
+ lower_l_eal_tensor,
+ upper_oklab_tensor, # l_oklab, a_oklab, b_oklab
+ lower_oklab_tensor,
+ upper_oklch_tensor, # l_oklab, c_oklab, h_oklab_degrees
+ lower_oklch_tensor,
+ upper_okhsv_tensor, # h_okhsv_degrees, s_okhsv, v_okhsv
+ lower_okhsv_tensor,
+ upper_okhsl_tensor, # h_okhsl_degrees, s_okhsl, l_r_oklab
+ lower_okhsl_tensor,
+ ) = image_tensors
+
+ current_space_tensors = {
+ "RGB": [upper_rgb_tensor, lower_rgb_tensor],
+ "Linear": [upper_rgb_l_tensor, lower_rgb_l_tensor],
+ "HSL": [upper_hsl_tensor, lower_hsl_tensor],
+ "HSV": [upper_hsv_tensor, lower_hsv_tensor],
+ "Okhsl": [upper_okhsl_tensor, lower_okhsl_tensor],
+ "Okhsv": [upper_okhsv_tensor, lower_okhsv_tensor],
+ "Oklch": [upper_oklch_tensor, lower_oklch_tensor],
+ "LCh": [upper_lch_tensor, lower_lch_tensor],
+ }[color_space]
+ upper_space_tensor = current_space_tensors[0]
+ lower_space_tensor = current_space_tensors[1]
+
+ lightness_index = {
+ "RGB": None,
+ "Linear": None,
+ "HSL": 2,
+ "HSV": 2,
+ "Okhsl": 2,
+ "Okhsv": 2,
+ "Oklch": 0,
+ "LCh": 0,
+ }[color_space]
+
+ saturation_index = {
+ "RGB": None,
+ "Linear": None,
+ "HSL": 1,
+ "HSV": 1,
+ "Okhsl": 1,
+ "Okhsv": 1,
+ "Oklch": 1,
+ "LCh": 1,
+ }[color_space]
+
+ hue_index = {
+ "RGB": None,
+ "Linear": None,
+ "HSL": 0,
+ "HSV": 0,
+ "Okhsl": 0,
+ "Okhsv": 0,
+ "Oklch": 2,
+ "LCh": 2,
+ }[color_space]
+
+ hue_period = {
+ "RGB": None,
+ "Linear": None,
+ "HSL": 360.0,
+ "HSV": 360.0,
+ "Okhsl": 360.0,
+ "Okhsv": 360.0,
+ "Oklch": 360.0,
+ "LCh": 2.0 * PI,
+ }[color_space]
+
+ if blend_mode == "Normal":
+ upper_rgb_l_tensor = reassembly_function(upper_space_tensor)
+
+ elif blend_mode == "Multiply":
+ upper_rgb_l_tensor = reassembly_function(torch.mul(lower_space_tensor, upper_space_tensor))
+
+ elif blend_mode == "Screen":
+ upper_rgb_l_tensor = reassembly_function(
+ torch.add(
+ torch.mul(
+ torch.mul(
+ torch.add(torch.mul(upper_space_tensor, -1.0), 1.0),
+ torch.add(torch.mul(lower_space_tensor, -1.0), 1.0),
+ ),
+ -1.0,
+ ),
+ 1.0,
+ )
+ )
+
+ elif (blend_mode == "Overlay") or (blend_mode == "Hard Light"):
+ subject_of_cond_tensor = lower_space_tensor if (blend_mode == "Overlay") else upper_space_tensor
+ if lightness_index is None:
+ upper_space_tensor = torch.where(
+ torch.lt(subject_of_cond_tensor, 0.5),
+ torch.mul(torch.mul(lower_space_tensor, upper_space_tensor), 2.0),
+ torch.add(
+ torch.mul(
+ torch.mul(
+ torch.mul(
+ torch.add(torch.mul(lower_space_tensor, -1.0), 1.0),
+ torch.add(torch.mul(upper_space_tensor, -1.0), 1.0),
+ ),
+ 2.0,
+ ),
+ -1.0,
+ ),
+ 1.0,
+ ),
+ )
+ else: # TODO: Currently blending only the lightness channel, not really ideal.
+ upper_space_tensor[lightness_index, :, :] = torch.where(
+ torch.lt(subject_of_cond_tensor[lightness_index, :, :], 0.5),
+ torch.mul(
+ torch.mul(lower_space_tensor[lightness_index, :, :], upper_space_tensor[lightness_index, :, :]),
+ 2.0,
+ ),
+ torch.add(
+ torch.mul(
+ torch.mul(
+ torch.mul(
+ torch.add(torch.mul(lower_space_tensor[lightness_index, :, :], -1.0), 1.0),
+ torch.add(torch.mul(upper_space_tensor[lightness_index, :, :], -1.0), 1.0),
+ ),
+ 2.0,
+ ),
+ -1.0,
+ ),
+ 1.0,
+ ),
+ )
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(upper_space_tensor))
+
+ elif blend_mode == "Soft Light":
+ if lightness_index is None:
+ g_tensor = torch.where(
+ torch.le(lower_space_tensor, 0.25),
+ torch.mul(
+ torch.add(
+ torch.mul(torch.sub(torch.mul(lower_space_tensor, 16.0), 12.0), lower_space_tensor), 4.0
+ ),
+ lower_space_tensor,
+ ),
+ torch.sqrt(lower_space_tensor),
+ )
+ lower_space_tensor = torch.where(
+ torch.le(upper_space_tensor, 0.5),
+ torch.sub(
+ lower_space_tensor,
+ torch.mul(
+ torch.mul(torch.add(torch.mul(lower_space_tensor, -1.0), 1.0), lower_space_tensor),
+ torch.add(torch.mul(torch.mul(upper_space_tensor, 2.0), -1.0), 1.0),
+ ),
+ ),
+ torch.add(
+ lower_space_tensor,
+ torch.mul(
+ torch.sub(torch.mul(upper_space_tensor, 2.0), 1.0), torch.sub(g_tensor, lower_space_tensor)
+ ),
+ ),
+ )
+ else:
+ print(
+ "\r\nCOND SHAPE:"
+ + str(torch.le(lower_space_tensor[lightness_index, :, :], 0.25).unsqueeze(0).shape)
+ + "\r\n"
+ )
+ g_tensor = torch.where( # Calculates all 3 channels but only one is currently used
+ torch.le(lower_space_tensor[lightness_index, :, :], 0.25).expand(upper_space_tensor.shape),
+ torch.mul(
+ torch.add(
+ torch.mul(torch.sub(torch.mul(lower_space_tensor, 16.0), 12.0), lower_space_tensor), 4.0
+ ),
+ lower_space_tensor,
+ ),
+ torch.sqrt(lower_space_tensor),
+ )
+ lower_space_tensor[lightness_index, :, :] = torch.where(
+ torch.le(upper_space_tensor[lightness_index, :, :], 0.5),
+ torch.sub(
+ lower_space_tensor[lightness_index, :, :],
+ torch.mul(
+ torch.mul(
+ torch.add(torch.mul(lower_space_tensor[lightness_index, :, :], -1.0), 1.0),
+ lower_space_tensor[lightness_index, :, :],
+ ),
+ torch.add(torch.mul(torch.mul(upper_space_tensor[lightness_index, :, :], 2.0), -1.0), 1.0),
+ ),
+ ),
+ torch.add(
+ lower_space_tensor[lightness_index, :, :],
+ torch.mul(
+ torch.sub(torch.mul(upper_space_tensor[lightness_index, :, :], 2.0), 1.0),
+ torch.sub(g_tensor[lightness_index, :, :], lower_space_tensor[lightness_index, :, :]),
+ ),
+ ),
+ )
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Linear Dodge (Add)":
+ lower_space_tensor = torch.add(lower_space_tensor, upper_space_tensor)
+ if hue_index is not None:
+ lower_space_tensor[hue_index, :, :] = torch.remainder(lower_space_tensor[hue_index, :, :], hue_period)
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Color Dodge":
+ lower_space_tensor = torch.div(lower_space_tensor, torch.add(torch.mul(upper_space_tensor, -1.0), 1.0))
+ if hue_index is not None:
+ lower_space_tensor[hue_index, :, :] = torch.remainder(lower_space_tensor[hue_index, :, :], hue_period)
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Divide":
+ lower_space_tensor = torch.div(lower_space_tensor, upper_space_tensor)
+ if hue_index is not None:
+ lower_space_tensor[hue_index, :, :] = torch.remainder(lower_space_tensor[hue_index, :, :], hue_period)
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Linear Burn":
+ # We compute the result in the lower image's current space tensor and return that:
+ if lightness_index is None: # Elementwise
+ lower_space_tensor = torch.sub(torch.add(lower_space_tensor, upper_space_tensor), 1.0)
+ else: # Operate only on the selected lightness channel
+ lower_space_tensor[lightness_index, :, :] = torch.sub(
+ torch.add(lower_space_tensor[lightness_index, :, :], upper_space_tensor[lightness_index, :, :]), 1.0
+ )
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Color Burn":
+ upper_rgb_l_tensor = adaptive_clipped(
+ reassembly_function(
+ torch.add(
+ torch.mul(
+ torch.min(
+ torch.div(torch.add(torch.mul(lower_space_tensor, -1.0), 1.0), upper_space_tensor),
+ torch.ones(lower_space_tensor.shape),
+ ),
+ -1.0,
+ ),
+ 1.0,
+ )
+ )
+ )
+ elif blend_mode == "Vivid Light":
+ if lightness_index is None:
+ lower_space_tensor = adaptive_clipped(
+ reassembly_function(
+ torch.where(
+ torch.lt(upper_space_tensor, 0.5),
+ torch.div(
+ torch.add(
+ torch.mul(
+ torch.div(
+ torch.add(torch.mul(lower_space_tensor, -1.0), 1.0), upper_space_tensor
+ ),
+ -1.0,
+ ),
+ 1.0,
+ ),
+ 2.0,
+ ),
+ torch.div(
+ torch.div(lower_space_tensor, torch.add(torch.mul(upper_space_tensor, -1.0), 1.0)), 2.0
+ ),
+ )
+ )
+ )
+ else:
+ lower_space_tensor[lightness_index, :, :] = torch.where(
+ torch.lt(upper_space_tensor[lightness_index, :, :], 0.5),
+ torch.div(
+ torch.add(
+ torch.mul(
+ torch.div(
+ torch.add(torch.mul(lower_space_tensor[lightness_index, :, :], -1.0), 1.0),
+ upper_space_tensor[lightness_index, :, :],
+ ),
+ -1.0,
+ ),
+ 1.0,
+ ),
+ 2.0,
+ ),
+ torch.div(
+ torch.div(
+ lower_space_tensor[lightness_index, :, :],
+ torch.add(torch.mul(upper_space_tensor[lightness_index, :, :], -1.0), 1.0),
+ ),
+ 2.0,
+ ),
+ )
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Linear Light":
+ if lightness_index is None:
+ lower_space_tensor = torch.sub(torch.add(lower_space_tensor, torch.mul(upper_space_tensor, 2.0)), 1.0)
+ else:
+ lower_space_tensor[lightness_index, :, :] = torch.sub(
+ torch.add(
+ lower_space_tensor[lightness_index, :, :],
+ torch.mul(upper_space_tensor[lightness_index, :, :], 2.0),
+ ),
+ 1.0,
+ )
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Subtract":
+ lower_space_tensor = torch.sub(lower_space_tensor, upper_space_tensor)
+ if hue_index is not None:
+ lower_space_tensor[hue_index, :, :] = torch.remainder(lower_space_tensor[hue_index, :, :], hue_period)
+ upper_rgb_l_tensor = adaptive_clipped(reassembly_function(lower_space_tensor))
+
+ elif blend_mode == "Difference":
+ upper_rgb_l_tensor = adaptive_clipped(
+ reassembly_function(torch.abs(torch.sub(lower_space_tensor, upper_space_tensor)))
+ )
+
+ elif (blend_mode == "Darken Only") or (blend_mode == "Lighten Only"):
+ extrema_fn = torch.min if (blend_mode == "Darken Only") else torch.max
+ comparator_fn = torch.ge if (blend_mode == "Darken Only") else torch.lt
+ if lightness_index is None:
+ upper_space_tensor = torch.stack(
+ [
+ extrema_fn(upper_space_tensor[0, :, :], lower_space_tensor[0, :, :]),
+ extrema_fn(upper_space_tensor[1, :, :], lower_space_tensor[1, :, :]),
+ extrema_fn(upper_space_tensor[2, :, :], lower_space_tensor[2, :, :]),
+ ]
+ )
+ else:
+ upper_space_tensor = torch.where(
+ comparator_fn(
+ upper_space_tensor[lightness_index, :, :], lower_space_tensor[lightness_index, :, :]
+ ).expand(upper_space_tensor.shape),
+ lower_space_tensor,
+ upper_space_tensor,
+ )
+ upper_rgb_l_tensor = reassembly_function(upper_space_tensor)
+
+ elif blend_mode in [
+ "Hue",
+ "Saturation",
+ "Color",
+ "Luminosity",
+ ]:
+ if blend_mode == "Hue": # l, c: lower / h: upper
+ upper_space_tensor[lightness_index, :, :] = lower_space_tensor[lightness_index, :, :]
+ upper_space_tensor[saturation_index, :, :] = lower_space_tensor[saturation_index, :, :]
+ elif blend_mode == "Saturation": # l, h: lower / c: upper
+ upper_space_tensor[lightness_index, :, :] = lower_space_tensor[lightness_index, :, :]
+ upper_space_tensor[hue_index, :, :] = lower_space_tensor[hue_index, :, :]
+ elif blend_mode == "Color": # l: lower / c, h: upper
+ upper_space_tensor[lightness_index, :, :] = lower_space_tensor[lightness_index, :, :]
+ elif blend_mode == "Luminosity": # h, c: lower / l: upper
+ upper_space_tensor[saturation_index, :, :] = lower_space_tensor[saturation_index, :, :]
+ upper_space_tensor[hue_index, :, :] = lower_space_tensor[hue_index, :, :]
+ upper_rgb_l_tensor = reassembly_function(upper_space_tensor)
+
+ elif blend_mode in ["Lighten Only (EAL)", "Darken Only (EAL)"]:
+ comparator_fn = torch.lt if (blend_mode == "Lighten Only (EAL)") else torch.ge
+ upper_space_tensor = torch.where(
+ comparator_fn(upper_l_eal_tensor, lower_l_eal_tensor).expand(upper_space_tensor.shape),
+ lower_space_tensor,
+ upper_space_tensor,
+ )
+ upper_rgb_l_tensor = reassembly_function(upper_space_tensor)
+
+ return upper_rgb_l_tensor
+
+ def alpha_composite(
+ self,
+ upper_tensor: torch.Tensor,
+ alpha_upper_tensor: torch.Tensor,
+ lower_tensor: torch.Tensor,
+ alpha_lower_tensor: torch.Tensor,
+ mask_tensor: Optional[torch.Tensor] = None,
+ ):
+ """Alpha compositing of upper on lower tensor with alpha channels, mask and scalar"""
+
+ upper_tensor = remove_nans(upper_tensor)
+
+ alpha_upper_tensor = torch.mul(alpha_upper_tensor, self.opacity)
+ if mask_tensor is not None:
+ alpha_upper_tensor = torch.mul(alpha_upper_tensor, torch.add(torch.mul(mask_tensor, -1.0), 1.0))
+
+ alpha_tensor = torch.add(
+ alpha_upper_tensor, torch.mul(alpha_lower_tensor, torch.add(torch.mul(alpha_upper_tensor, -1.0), 1.0))
+ )
+
+ return (
+ torch.div(
+ torch.add(
+ torch.mul(upper_tensor, alpha_upper_tensor),
+ torch.mul(
+ torch.mul(lower_tensor, alpha_lower_tensor), torch.add(torch.mul(alpha_upper_tensor, -1.0), 1.0)
+ ),
+ ),
+ alpha_tensor,
+ ),
+ alpha_tensor,
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ """Main execution of the ImageBlendInvocation node"""
+
+ image_upper = context.images.get_pil(self.layer_upper.image_name)
+ image_base = context.images.get_pil(self.layer_base.image_name)
+
+ # Keep the modes for restoration after processing:
+ image_mode_base = image_base.mode
+
+ # Get rid of ICC profiles by converting to sRGB, but save for restoration:
+ cms_profile_srgb = None
+ if "icc_profile" in image_upper.info:
+ cms_profile_upper = BytesIO(image_upper.info["icc_profile"])
+ cms_profile_srgb = ImageCms.createProfile("sRGB")
+ cms_xform = ImageCms.buildTransformFromOpenProfiles(
+ cms_profile_upper, cms_profile_srgb, image_upper.mode, "RGBA"
+ )
+ image_upper = ImageCms.applyTransform(image_upper, cms_xform)
+
+ cms_profile_base = None
+ icc_profile_bytes = None
+ if "icc_profile" in image_base.info:
+ icc_profile_bytes = image_base.info["icc_profile"]
+ cms_profile_base = BytesIO(icc_profile_bytes)
+ if cms_profile_srgb is None:
+ cms_profile_srgb = ImageCms.createProfile("sRGB")
+ cms_xform = ImageCms.buildTransformFromOpenProfiles(
+ cms_profile_base, cms_profile_srgb, image_base.mode, "RGBA"
+ )
+ image_base = ImageCms.applyTransform(image_base, cms_xform)
+
+ image_mask = None
+ if self.mask is not None:
+ image_mask = context.images.get_pil(self.mask.image_name)
+ color_space = self.color_space.split()[0]
+
+ image_upper = self.scale_and_pad_or_crop_to_base(image_upper, image_base)
+ if image_mask is not None:
+ image_mask = self.scale_and_pad_or_crop_to_base(image_mask, image_base)
+
+ tensor_requirements = []
+
+ # Hue, Saturation, Color, and Luminosity won't work in sRGB, require HSL
+ if self.blend_mode in ["Hue", "Saturation", "Color", "Luminosity"] and self.color_space in [
+ "RGB",
+ "Linear RGB",
+ ]:
+ tensor_requirements = ["hsl"]
+
+ if self.blend_mode in ["Lighten Only (EAL)", "Darken Only (EAL)"]:
+ tensor_requirements = tensor_requirements + ["lch", "l_eal"]
+
+ tensor_requirements += {
+ "Linear": [],
+ "RGB": [],
+ "HSL": ["hsl"],
+ "HSV": ["hsv"],
+ "Okhsl": ["okhsl"],
+ "Okhsv": ["okhsv"],
+ "Oklch": ["oklch"],
+ "LCh": ["lch"],
+ }[color_space]
+
+ image_tensors = (
+ upper_rgb_l_tensor, # linear-light sRGB
+ lower_rgb_l_tensor, # linear-light sRGB
+ upper_rgb_tensor,
+ lower_rgb_tensor,
+ alpha_upper_tensor,
+ alpha_lower_tensor,
+ mask_tensor,
+ upper_hsv_tensor,
+ lower_hsv_tensor,
+ upper_hsl_tensor,
+ lower_hsl_tensor,
+ upper_lab_tensor,
+ lower_lab_tensor,
+ upper_lch_tensor,
+ lower_lch_tensor,
+ upper_l_eal_tensor,
+ lower_l_eal_tensor,
+ upper_oklab_tensor,
+ lower_oklab_tensor,
+ upper_oklch_tensor,
+ lower_oklch_tensor,
+ upper_okhsv_tensor,
+ lower_okhsv_tensor,
+ upper_okhsl_tensor,
+ lower_okhsl_tensor,
+ ) = self.prepare_tensors_from_images(
+ image_upper, image_base, mask_image=image_mask, required=tensor_requirements
+ )
+
+ # if not (self.blend_mode == "Normal"):
+ upper_rgb_l_tensor = self.apply_blend(image_tensors)
+
+ output_tensor, alpha_tensor = self.alpha_composite(
+ srgb_from_linear_srgb(
+ upper_rgb_l_tensor, alpha=self.adaptive_gamut, steps=(3 if self.high_precision else 1)
+ ),
+ alpha_upper_tensor,
+ lower_rgb_tensor,
+ alpha_lower_tensor,
+ mask_tensor=mask_tensor,
+ )
+
+ # Restore alpha channel and base mode:
+ output_tensor = torch.stack(
+ [output_tensor[0, :, :], output_tensor[1, :, :], output_tensor[2, :, :], alpha_tensor]
+ )
+ image_out = pil_image_from_tensor(output_tensor, mode="RGBA")
+
+ # Restore ICC profile if base image had one:
+ if cms_profile_base is not None:
+ cms_xform = ImageCms.buildTransformFromOpenProfiles(
+ cms_profile_srgb, BytesIO(icc_profile_bytes), "RGBA", image_out.mode
+ )
+ image_out = ImageCms.applyTransform(image_out, cms_xform)
+ else:
+ image_out = image_out.convert(image_mode_base)
+
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "invokeai_img_composite",
+ title="Image Compositor",
+ tags=["image", "compose", "chroma", "key"],
+ category="image",
+ version="1.2.0",
+)
+class InvokeImageCompositorInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Removes backdrop from subject image then overlays subject on background image. Originally created by @dwringer"""
+
+ image_subject: ImageField = InputField(description="Image of the subject on a plain monochrome background")
+ image_background: ImageField = InputField(description="Image of a background scene")
+ chroma_key: str = InputField(
+ default="", description="Can be empty for corner flood select, or CSS-3 color or tuple"
+ )
+ threshold: int = InputField(ge=0, default=50, description="Subject isolation flood-fill threshold")
+ fill_x: bool = InputField(default=False, description="Scale base subject image to fit background width")
+ fill_y: bool = InputField(default=True, description="Scale base subject image to fit background height")
+ x_offset: int = InputField(default=0, description="x-offset for the subject")
+ y_offset: int = InputField(default=0, description="y-offset for the subject")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_background = context.images.get_pil(self.image_background.image_name).convert(mode="RGBA")
+ image_subject = context.images.get_pil(self.image_subject.image_name).convert(mode="RGBA")
+
+ if image_subject.height == 0 or image_subject.width == 0:
+ raise ValueError("The subject image has zero height or width")
+ if image_background.height == 0 or image_background.width == 0:
+ raise ValueError("The subject image has zero height or width")
+
+ # Handle backdrop removal:
+ chroma_key = self.chroma_key.strip()
+ if 0 < len(chroma_key):
+ # Remove pixels by chroma key:
+ if chroma_key[0] == "(":
+ chroma_key = tuple_from_string(chroma_key)
+ while len(chroma_key) < 3:
+ chroma_key = tuple(list(chroma_key) + [0])
+ if len(chroma_key) == 3:
+ chroma_key = tuple(list(chroma_key) + [255])
+ else:
+ chroma_key = ImageColor.getcolor(chroma_key, "RGBA")
+ threshold = self.threshold**2.0 # to compare vs squared color distance from key
+ pixels = image_subject.load()
+ if pixels is None:
+ raise ValueError("Unable to load pixels from subject image")
+ for i in range(image_subject.width):
+ for j in range(image_subject.height):
+ if (
+ reduce(
+ lambda a, b: a + b, [(pixels[i, j][k] - chroma_key[k]) ** 2 for k in range(len(chroma_key))]
+ )
+ < threshold
+ ):
+ pixels[i, j] = tuple([0 for k in range(len(chroma_key))])
+ else:
+ # Remove pixels by flood select from corners:
+ ImageDraw.floodfill(image_subject, (0, 0), (0, 0, 0, 0), thresh=self.threshold)
+ ImageDraw.floodfill(image_subject, (0, image_subject.height - 1), (0, 0, 0, 0), thresh=self.threshold)
+ ImageDraw.floodfill(image_subject, (image_subject.width - 1, 0), (0, 0, 0, 0), thresh=self.threshold)
+ ImageDraw.floodfill(
+ image_subject, (image_subject.width - 1, image_subject.height - 1), (0, 0, 0, 0), thresh=self.threshold
+ )
+
+ # Scale and position the subject:
+ aspect_background = image_background.width / image_background.height
+ aspect_subject = image_subject.width / image_subject.height
+ if self.fill_x and self.fill_y:
+ image_subject = image_subject.resize((image_background.width, image_background.height))
+ elif (self.fill_x and (aspect_background < aspect_subject)) or (
+ self.fill_y and (aspect_subject <= aspect_background)
+ ):
+ image_subject = ImageOps.pad(
+ image_subject, (image_background.width, image_background.height), color=(0, 0, 0, 0)
+ )
+ elif (self.fill_x and (aspect_subject <= aspect_background)) or (
+ self.fill_y and (aspect_background < aspect_subject)
+ ):
+ image_subject = ImageOps.fit(image_subject, (image_background.width, image_background.height))
+ if (self.x_offset != 0) or (self.y_offset != 0):
+ image_subject = ImageChops.offset(image_subject, self.x_offset, yoffset=-1 * self.y_offset)
+
+ new_image = Image.alpha_composite(image_background, image_subject)
+ new_image.convert(mode="RGB")
+ image_dto = context.images.save(new_image)
+
+ return ImageOutput.build(image_dto)
+
+
+DILATE_ERODE_MODES = Literal[
+ "Dilate",
+ "Erode",
+]
+
+
+@invocation(
+ "invokeai_img_dilate_erode",
+ title="Image Dilate or Erode",
+ tags=["image", "mask", "dilate", "erode", "expand", "contract", "mask"],
+ category="image",
+ version="1.3.0",
+)
+class InvokeImageDilateOrErodeInvocation(BaseInvocation, WithMetadata):
+ """Dilate (expand) or erode (contract) an image. Originally created by @dwringer"""
+
+ image: ImageField = InputField(description="The image from which to create a mask")
+ lightness_only: bool = InputField(default=False, description="If true, only applies to image lightness (CIELa*b*)")
+ radius_w: int = InputField(
+ ge=0, default=4, description="Width (in pixels) by which to dilate(expand) or erode (contract) the image"
+ )
+ radius_h: int = InputField(
+ ge=0, default=4, description="Height (in pixels) by which to dilate(expand) or erode (contract) the image"
+ )
+ mode: DILATE_ERODE_MODES = InputField(default="Dilate", description="How to operate on the image")
+
+ def expand_or_contract(self, image_in: Image.Image):
+ image_out = numpy.array(image_in)
+ expand_radius_w = self.radius_w
+ expand_radius_h = self.radius_h
+
+ expand_fn = None
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (expand_radius_w * 2 + 1, expand_radius_h * 2 + 1))
+ if self.mode == "Dilate":
+ expand_fn = cv2.dilate
+ elif self.mode == "Erode":
+ expand_fn = cv2.erode
+ else:
+ raise ValueError("Invalid mode selected")
+ image_out = expand_fn(image_out, kernel, iterations=1)
+ return Image.fromarray(image_out, mode=image_in.mode)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_in = context.images.get_pil(self.image.image_name)
+ image_out = image_in
+
+ if self.lightness_only:
+ image_mode = image_in.mode
+ alpha_channel = None
+ if (image_mode == "RGBA") or (image_mode == "LA") or (image_mode == "PA"):
+ alpha_channel = image_in.getchannel("A")
+ elif (image_mode == "RGBa") or (image_mode == "La") or (image_mode == "Pa"):
+ alpha_channel = image_in.getchannel("a")
+ if (image_mode == "RGBA") or (image_mode == "RGBa"):
+ image_mode = "RGB"
+ elif (image_mode == "LA") or (image_mode == "La"):
+ image_mode = "L"
+ elif image_mode == "PA":
+ image_mode = "P"
+ image_out = image_out.convert("RGB")
+ image_out = image_out.convert("LAB")
+ l_channel = self.expand_or_contract(image_out.getchannel("L"))
+ image_out = Image.merge("LAB", (l_channel, image_out.getchannel("A"), image_out.getchannel("B")))
+ if (image_mode == "L") or (image_mode == "P"):
+ image_out = image_out.convert("RGB")
+ image_out = image_out.convert(image_mode)
+ if "a" in image_in.mode.lower():
+ image_out = Image.merge(
+ image_in.mode, tuple([image_out.getchannel(c) for c in image_mode] + [alpha_channel])
+ )
+ else:
+ image_out = self.expand_or_contract(image_out)
+
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "invokeai_img_val_thresholds",
+ title="Image Value Thresholds",
+ tags=["image", "mask", "value", "threshold"],
+ category="image",
+ version="1.2.0",
+)
+class InvokeImageValueThresholdsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Clip image to pure black/white past specified thresholds. Originally created by @dwringer"""
+
+ image: ImageField = InputField(description="The image from which to create a mask")
+ invert_output: bool = InputField(default=False, description="Make light areas dark and vice versa")
+ renormalize_values: bool = InputField(default=False, description="Rescale remaining values from minimum to maximum")
+ lightness_only: bool = InputField(default=False, description="If true, only applies to image lightness (CIELa*b*)")
+ threshold_upper: float = InputField(default=0.5, description="Threshold above which will be set to full value")
+ threshold_lower: float = InputField(default=0.5, description="Threshold below which will be set to minimum value")
+
+ def get_threshold_mask(self, image_tensor: torch.Tensor):
+ img_tensor = image_tensor.clone()
+ threshold_h, threshold_s = self.threshold_upper, self.threshold_lower
+ ones_tensor = torch.ones(img_tensor.shape)
+ zeros_tensor = torch.zeros(img_tensor.shape)
+
+ zeros_mask, ones_mask = None, None
+ if self.invert_output:
+ zeros_mask, ones_mask = torch.ge(img_tensor, threshold_h), torch.lt(img_tensor, threshold_s)
+ else:
+ ones_mask, zeros_mask = torch.ge(img_tensor, threshold_h), torch.lt(img_tensor, threshold_s)
+
+ if not (threshold_h == threshold_s):
+ mask_hi = torch.ge(img_tensor, threshold_s)
+ mask_lo = torch.lt(img_tensor, threshold_h)
+ mask = torch.logical_and(mask_hi, mask_lo)
+ masked = img_tensor[mask]
+ if 0 < masked.numel():
+ if self.renormalize_values:
+ vmax, vmin = max(threshold_h, threshold_s), min(threshold_h, threshold_s)
+ if vmax == vmin:
+ img_tensor[mask] = vmin * ones_tensor[mask]
+ elif self.invert_output:
+ img_tensor[mask] = torch.sub(1.0, (img_tensor[mask] - vmin) / (vmax - vmin))
+ else:
+ img_tensor[mask] = (img_tensor[mask] - vmin) / (vmax - vmin)
+
+ img_tensor[ones_mask] = ones_tensor[ones_mask]
+ img_tensor[zeros_mask] = zeros_tensor[zeros_mask]
+
+ return img_tensor
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image_in = context.images.get_pil(self.image.image_name)
+
+ if self.lightness_only:
+ image_mode = image_in.mode
+ alpha_channel = None
+ if (image_mode == "RGBA") or (image_mode == "LA") or (image_mode == "PA"):
+ alpha_channel = image_in.getchannel("A")
+ elif (image_mode == "RGBa") or (image_mode == "La") or (image_mode == "Pa"):
+ alpha_channel = image_in.getchannel("a")
+ if (image_mode == "RGBA") or (image_mode == "RGBa"):
+ image_mode = "RGB"
+ elif (image_mode == "LA") or (image_mode == "La"):
+ image_mode = "L"
+ elif image_mode == "PA":
+ image_mode = "P"
+ image_out = image_in.convert("RGB")
+ image_out = image_out.convert("LAB")
+
+ l_channel = image_resized_to_grid_as_tensor(image_out.getchannel("L"), normalize=False)
+ l_channel = self.get_threshold_mask(l_channel)
+ l_channel = pil_image_from_tensor(l_channel)
+
+ image_out = Image.merge("LAB", (l_channel, image_out.getchannel("A"), image_out.getchannel("B")))
+ if (image_mode == "L") or (image_mode == "P"):
+ image_out = image_out.convert("RGB")
+ image_out = image_out.convert(image_mode)
+ if "a" in image_in.mode.lower():
+ image_out = Image.merge(
+ image_in.mode, tuple([image_out.getchannel(c) for c in image_mode] + [alpha_channel])
+ )
+ else:
+ image_out = image_resized_to_grid_as_tensor(image_in, normalize=False)
+ image_out = self.get_threshold_mask(image_out)
+ image_out = pil_image_from_tensor(image_out)
+
+ image_dto = context.images.save(image_out)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/constants.py b/invokeai/app/invocations/constants.py
index e01589be812..314890a0f8f 100644
--- a/invokeai/app/invocations/constants.py
+++ b/invokeai/app/invocations/constants.py
@@ -1,8 +1,5 @@
from typing import Literal
-from invokeai.backend.stable_diffusion.schedulers import SCHEDULER_MAP
-from invokeai.backend.util.devices import TorchDevice
-
LATENT_SCALE_FACTOR = 8
"""
HACK: Many nodes are currently hard-coded to use a fixed latent scale factor of 8. This is fragile, and will need to
@@ -11,10 +8,5 @@
The ratio of image:latent dimensions is LATENT_SCALE_FACTOR:1, or 8:1.
"""
-SCHEDULER_NAME_VALUES = Literal[tuple(SCHEDULER_MAP.keys())]
-"""A literal type representing the valid scheduler names."""
-
IMAGE_MODES = Literal["L", "RGB", "RGBA", "CMYK", "YCbCr", "LAB", "HSV", "I", "F"]
"""A literal type for PIL image modes supported by Invoke"""
-
-DEFAULT_PRECISION = TorchDevice.choose_torch_dtype()
diff --git a/invokeai/app/invocations/content_shuffle.py b/invokeai/app/invocations/content_shuffle.py
new file mode 100644
index 00000000000..6fd35b53eb2
--- /dev/null
+++ b/invokeai/app/invocations/content_shuffle.py
@@ -0,0 +1,25 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.content_shuffle import content_shuffle
+
+
+@invocation(
+ "content_shuffle",
+ title="Content Shuffle",
+ tags=["controlnet", "normal"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class ContentShuffleInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Shuffles the image, similar to a 'liquify' filter."""
+
+ image: ImageField = InputField(description="The image to process")
+ scale_factor: int = InputField(default=256, ge=0, description="The scale factor used for the shuffle")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ output_image = content_shuffle(input_image=image, scale_factor=self.scale_factor)
+ image_dto = context.images.save(image=output_image)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/controlnet.py b/invokeai/app/invocations/controlnet.py
new file mode 100644
index 00000000000..9b0fc8219b2
--- /dev/null
+++ b/invokeai/app/invocations/controlnet.py
@@ -0,0 +1,136 @@
+# Invocations for ControlNet image preprocessors
+# initial implementation by Gregg Helt, 2023
+from typing import List, Union
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ InputField,
+ OutputField,
+)
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.controlnet_utils import (
+ CONTROLNET_MODE_VALUES,
+ CONTROLNET_RESIZE_VALUES,
+ heuristic_resize_fast,
+)
+from invokeai.backend.image_util.util import np_to_pil, pil_to_np
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+class ControlField(BaseModel):
+ image: ImageField = Field(description="The control image")
+ control_model: ModelIdentifierField = Field(description="The ControlNet model to use")
+ control_weight: Union[float, List[float]] = Field(default=1, description="The weight given to the ControlNet")
+ begin_step_percent: float = Field(
+ default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
+ )
+ end_step_percent: float = Field(
+ default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
+ )
+ control_mode: CONTROLNET_MODE_VALUES = Field(default="balanced", description="The control mode to use")
+ resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use")
+
+ @field_validator("control_weight")
+ @classmethod
+ def validate_control_weight(cls, v):
+ validate_weights(v)
+ return v
+
+ @model_validator(mode="after")
+ def validate_begin_end_step_percent(self):
+ validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
+ return self
+
+
+@invocation_output("control_output")
+class ControlOutput(BaseInvocationOutput):
+ """node output for ControlNet info"""
+
+ # Outputs
+ control: ControlField = OutputField(description=FieldDescriptions.control)
+
+
+@invocation(
+ "controlnet", title="ControlNet - SD1.5, SD2, SDXL", tags=["controlnet"], category="conditioning", version="1.1.3"
+)
+class ControlNetInvocation(BaseInvocation):
+ """Collects ControlNet info to pass to other nodes"""
+
+ image: ImageField = InputField(description="The control image")
+ control_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.controlnet_model,
+ ui_model_base=[BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2, BaseModelType.StableDiffusionXL],
+ ui_model_type=ModelType.ControlNet,
+ )
+ control_weight: Union[float, List[float]] = InputField(
+ default=1.0, ge=-1, le=2, description="The weight given to the ControlNet"
+ )
+ begin_step_percent: float = InputField(
+ default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
+ )
+ end_step_percent: float = InputField(
+ default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
+ )
+ control_mode: CONTROLNET_MODE_VALUES = InputField(default="balanced", description="The control mode used")
+ resize_mode: CONTROLNET_RESIZE_VALUES = InputField(default="just_resize", description="The resize mode used")
+
+ @field_validator("control_weight")
+ @classmethod
+ def validate_control_weight(cls, v):
+ validate_weights(v)
+ return v
+
+ @model_validator(mode="after")
+ def validate_begin_end_step_percent(self) -> "ControlNetInvocation":
+ validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
+ return self
+
+ def invoke(self, context: InvocationContext) -> ControlOutput:
+ return ControlOutput(
+ control=ControlField(
+ image=self.image,
+ control_model=self.control_model,
+ control_weight=self.control_weight,
+ begin_step_percent=self.begin_step_percent,
+ end_step_percent=self.end_step_percent,
+ control_mode=self.control_mode,
+ resize_mode=self.resize_mode,
+ ),
+ )
+
+
+@invocation(
+ "heuristic_resize",
+ title="Heuristic Resize",
+ tags=["image, controlnet"],
+ category="controlnet_preprocessors",
+ version="1.1.1",
+ classification=Classification.Prototype,
+)
+class HeuristicResizeInvocation(BaseInvocation):
+ """Resize an image using a heuristic method. Preserves edge maps."""
+
+ image: ImageField = InputField(description="The image to resize")
+ width: int = InputField(default=512, ge=1, description="The width to resize to (px)")
+ height: int = InputField(default=512, ge=1, description="The height to resize to (px)")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ np_img = pil_to_np(image)
+ np_resized = heuristic_resize_fast(np_img, (self.width, self.height))
+ resized = np_to_pil(np_resized)
+ image_dto = context.images.save(image=resized)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py
deleted file mode 100644
index c0b332f27b6..00000000000
--- a/invokeai/app/invocations/controlnet_image_processors.py
+++ /dev/null
@@ -1,673 +0,0 @@
-# Invocations for ControlNet image preprocessors
-# initial implementation by Gregg Helt, 2023
-# heavily leverages controlnet_aux package: https://github.com/patrickvonplaten/controlnet_aux
-from builtins import bool, float
-from pathlib import Path
-from typing import Dict, List, Literal, Union
-
-import cv2
-import numpy as np
-from controlnet_aux import (
- ContentShuffleDetector,
- LeresDetector,
- MediapipeFaceDetector,
- MidasDetector,
- MLSDdetector,
- NormalBaeDetector,
- PidiNetDetector,
- SamDetector,
- ZoeDetector,
-)
-from controlnet_aux.util import HWC3, ade_palette
-from PIL import Image
-from pydantic import BaseModel, Field, field_validator, model_validator
-
-from invokeai.app.invocations.fields import (
- FieldDescriptions,
- ImageField,
- InputField,
- OutputField,
- UIType,
- WithBoard,
- WithMetadata,
-)
-from invokeai.app.invocations.model import ModelIdentifierField
-from invokeai.app.invocations.primitives import ImageOutput
-from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
-from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES, heuristic_resize
-from invokeai.backend.image_util.canny import get_canny_edges
-from invokeai.backend.image_util.depth_anything import DEPTH_ANYTHING_MODELS, DepthAnythingDetector
-from invokeai.backend.image_util.dw_openpose import DWPOSE_MODELS, DWOpenposeDetector
-from invokeai.backend.image_util.hed import HEDProcessor
-from invokeai.backend.image_util.lineart import LineartProcessor
-from invokeai.backend.image_util.lineart_anime import LineartAnimeProcessor
-from invokeai.backend.image_util.util import np_to_pil, pil_to_np
-from invokeai.backend.util.devices import TorchDevice
-
-from .baseinvocation import BaseInvocation, BaseInvocationOutput, Classification, invocation, invocation_output
-
-
-class ControlField(BaseModel):
- image: ImageField = Field(description="The control image")
- control_model: ModelIdentifierField = Field(description="The ControlNet model to use")
- control_weight: Union[float, List[float]] = Field(default=1, description="The weight given to the ControlNet")
- begin_step_percent: float = Field(
- default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
- )
- end_step_percent: float = Field(
- default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
- )
- control_mode: CONTROLNET_MODE_VALUES = Field(default="balanced", description="The control mode to use")
- resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use")
-
- @field_validator("control_weight")
- @classmethod
- def validate_control_weight(cls, v):
- validate_weights(v)
- return v
-
- @model_validator(mode="after")
- def validate_begin_end_step_percent(self):
- validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
- return self
-
-
-@invocation_output("control_output")
-class ControlOutput(BaseInvocationOutput):
- """node output for ControlNet info"""
-
- # Outputs
- control: ControlField = OutputField(description=FieldDescriptions.control)
-
-
-@invocation("controlnet", title="ControlNet", tags=["controlnet"], category="controlnet", version="1.1.2")
-class ControlNetInvocation(BaseInvocation):
- """Collects ControlNet info to pass to other nodes"""
-
- image: ImageField = InputField(description="The control image")
- control_model: ModelIdentifierField = InputField(
- description=FieldDescriptions.controlnet_model, ui_type=UIType.ControlNetModel
- )
- control_weight: Union[float, List[float]] = InputField(
- default=1.0, ge=-1, le=2, description="The weight given to the ControlNet"
- )
- begin_step_percent: float = InputField(
- default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
- )
- end_step_percent: float = InputField(
- default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
- )
- control_mode: CONTROLNET_MODE_VALUES = InputField(default="balanced", description="The control mode used")
- resize_mode: CONTROLNET_RESIZE_VALUES = InputField(default="just_resize", description="The resize mode used")
-
- @field_validator("control_weight")
- @classmethod
- def validate_control_weight(cls, v):
- validate_weights(v)
- return v
-
- @model_validator(mode="after")
- def validate_begin_end_step_percent(self) -> "ControlNetInvocation":
- validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
- return self
-
- def invoke(self, context: InvocationContext) -> ControlOutput:
- return ControlOutput(
- control=ControlField(
- image=self.image,
- control_model=self.control_model,
- control_weight=self.control_weight,
- begin_step_percent=self.begin_step_percent,
- end_step_percent=self.end_step_percent,
- control_mode=self.control_mode,
- resize_mode=self.resize_mode,
- ),
- )
-
-
-# This invocation exists for other invocations to subclass it - do not register with @invocation!
-class ImageProcessorInvocation(BaseInvocation, WithMetadata, WithBoard):
- """Base class for invocations that preprocess images for ControlNet"""
-
- image: ImageField = InputField(description="The image to process")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- # superclass just passes through image without processing
- return image
-
- def load_image(self, context: InvocationContext) -> Image.Image:
- # allows override for any special formatting specific to the preprocessor
- return context.images.get_pil(self.image.image_name, "RGB")
-
- def invoke(self, context: InvocationContext) -> ImageOutput:
- self._context = context
- raw_image = self.load_image(context)
- # image type should be PIL.PngImagePlugin.PngImageFile ?
- processed_image = self.run_processor(raw_image)
-
- # currently can't see processed image in node UI without a showImage node,
- # so for now setting image_type to RESULT instead of INTERMEDIATE so will get saved in gallery
- image_dto = context.images.save(image=processed_image)
-
- """Builds an ImageOutput and its ImageField"""
- processed_image_field = ImageField(image_name=image_dto.image_name)
- return ImageOutput(
- image=processed_image_field,
- # width=processed_image.width,
- width=image_dto.width,
- # height=processed_image.height,
- height=image_dto.height,
- # mode=processed_image.mode,
- )
-
-
-@invocation(
- "canny_image_processor",
- title="Canny Processor",
- tags=["controlnet", "canny"],
- category="controlnet",
- version="1.3.3",
-)
-class CannyImageProcessorInvocation(ImageProcessorInvocation):
- """Canny edge detection for ControlNet"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- low_threshold: int = InputField(
- default=100, ge=0, le=255, description="The low threshold of the Canny pixel gradient (0-255)"
- )
- high_threshold: int = InputField(
- default=200, ge=0, le=255, description="The high threshold of the Canny pixel gradient (0-255)"
- )
-
- def load_image(self, context: InvocationContext) -> Image.Image:
- # Keep alpha channel for Canny processing to detect edges of transparent areas
- return context.images.get_pil(self.image.image_name, "RGBA")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- processed_image = get_canny_edges(
- image,
- self.low_threshold,
- self.high_threshold,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- )
- return processed_image
-
-
-@invocation(
- "hed_image_processor",
- title="HED (softedge) Processor",
- tags=["controlnet", "hed", "softedge"],
- category="controlnet",
- version="1.2.3",
-)
-class HedImageProcessorInvocation(ImageProcessorInvocation):
- """Applies HED edge detection to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- # safe not supported in controlnet_aux v0.0.3
- # safe: bool = InputField(default=False, description=FieldDescriptions.safe_mode)
- scribble: bool = InputField(default=False, description=FieldDescriptions.scribble_mode)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- hed_processor = HEDProcessor()
- processed_image = hed_processor.run(
- image,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- # safe not supported in controlnet_aux v0.0.3
- # safe=self.safe,
- scribble=self.scribble,
- )
- return processed_image
-
-
-@invocation(
- "lineart_image_processor",
- title="Lineart Processor",
- tags=["controlnet", "lineart"],
- category="controlnet",
- version="1.2.3",
-)
-class LineartImageProcessorInvocation(ImageProcessorInvocation):
- """Applies line art processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- coarse: bool = InputField(default=False, description="Whether to use coarse mode")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- lineart_processor = LineartProcessor()
- processed_image = lineart_processor.run(
- image, detect_resolution=self.detect_resolution, image_resolution=self.image_resolution, coarse=self.coarse
- )
- return processed_image
-
-
-@invocation(
- "lineart_anime_image_processor",
- title="Lineart Anime Processor",
- tags=["controlnet", "lineart", "anime"],
- category="controlnet",
- version="1.2.3",
-)
-class LineartAnimeImageProcessorInvocation(ImageProcessorInvocation):
- """Applies line art anime processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- processor = LineartAnimeProcessor()
- processed_image = processor.run(
- image,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- )
- return processed_image
-
-
-@invocation(
- "midas_depth_image_processor",
- title="Midas Depth Processor",
- tags=["controlnet", "midas"],
- category="controlnet",
- version="1.2.4",
-)
-class MidasDepthImageProcessorInvocation(ImageProcessorInvocation):
- """Applies Midas depth processing to image"""
-
- a_mult: float = InputField(default=2.0, ge=0, description="Midas parameter `a_mult` (a = a_mult * PI)")
- bg_th: float = InputField(default=0.1, ge=0, description="Midas parameter `bg_th`")
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- # depth_and_normal not supported in controlnet_aux v0.0.3
- # depth_and_normal: bool = InputField(default=False, description="whether to use depth and normal mode")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- # TODO: replace from_pretrained() calls with context.models.download_and_cache() (or similar)
- midas_processor = MidasDetector.from_pretrained("lllyasviel/Annotators")
- processed_image = midas_processor(
- image,
- a=np.pi * self.a_mult,
- bg_th=self.bg_th,
- image_resolution=self.image_resolution,
- detect_resolution=self.detect_resolution,
- # dept_and_normal not supported in controlnet_aux v0.0.3
- # depth_and_normal=self.depth_and_normal,
- )
- return processed_image
-
-
-@invocation(
- "normalbae_image_processor",
- title="Normal BAE Processor",
- tags=["controlnet"],
- category="controlnet",
- version="1.2.3",
-)
-class NormalbaeImageProcessorInvocation(ImageProcessorInvocation):
- """Applies NormalBae processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- normalbae_processor = NormalBaeDetector.from_pretrained("lllyasviel/Annotators")
- processed_image = normalbae_processor(
- image, detect_resolution=self.detect_resolution, image_resolution=self.image_resolution
- )
- return processed_image
-
-
-@invocation(
- "mlsd_image_processor", title="MLSD Processor", tags=["controlnet", "mlsd"], category="controlnet", version="1.2.3"
-)
-class MlsdImageProcessorInvocation(ImageProcessorInvocation):
- """Applies MLSD processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- thr_v: float = InputField(default=0.1, ge=0, description="MLSD parameter `thr_v`")
- thr_d: float = InputField(default=0.1, ge=0, description="MLSD parameter `thr_d`")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- mlsd_processor = MLSDdetector.from_pretrained("lllyasviel/Annotators")
- processed_image = mlsd_processor(
- image,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- thr_v=self.thr_v,
- thr_d=self.thr_d,
- )
- return processed_image
-
-
-@invocation(
- "pidi_image_processor", title="PIDI Processor", tags=["controlnet", "pidi"], category="controlnet", version="1.2.3"
-)
-class PidiImageProcessorInvocation(ImageProcessorInvocation):
- """Applies PIDI processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- safe: bool = InputField(default=False, description=FieldDescriptions.safe_mode)
- scribble: bool = InputField(default=False, description=FieldDescriptions.scribble_mode)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- pidi_processor = PidiNetDetector.from_pretrained("lllyasviel/Annotators")
- processed_image = pidi_processor(
- image,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- safe=self.safe,
- scribble=self.scribble,
- )
- return processed_image
-
-
-@invocation(
- "content_shuffle_image_processor",
- title="Content Shuffle Processor",
- tags=["controlnet", "contentshuffle"],
- category="controlnet",
- version="1.2.3",
-)
-class ContentShuffleImageProcessorInvocation(ImageProcessorInvocation):
- """Applies content shuffle processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
- h: int = InputField(default=512, ge=0, description="Content shuffle `h` parameter")
- w: int = InputField(default=512, ge=0, description="Content shuffle `w` parameter")
- f: int = InputField(default=256, ge=0, description="Content shuffle `f` parameter")
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- content_shuffle_processor = ContentShuffleDetector()
- processed_image = content_shuffle_processor(
- image,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- h=self.h,
- w=self.w,
- f=self.f,
- )
- return processed_image
-
-
-# should work with controlnet_aux >= 0.0.4 and timm <= 0.6.13
-@invocation(
- "zoe_depth_image_processor",
- title="Zoe (Depth) Processor",
- tags=["controlnet", "zoe", "depth"],
- category="controlnet",
- version="1.2.3",
-)
-class ZoeDepthImageProcessorInvocation(ImageProcessorInvocation):
- """Applies Zoe depth processing to image"""
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- zoe_depth_processor = ZoeDetector.from_pretrained("lllyasviel/Annotators")
- processed_image = zoe_depth_processor(image)
- return processed_image
-
-
-@invocation(
- "mediapipe_face_processor",
- title="Mediapipe Face Processor",
- tags=["controlnet", "mediapipe", "face"],
- category="controlnet",
- version="1.2.4",
-)
-class MediapipeFaceProcessorInvocation(ImageProcessorInvocation):
- """Applies mediapipe face processing to image"""
-
- max_faces: int = InputField(default=1, ge=1, description="Maximum number of faces to detect")
- min_confidence: float = InputField(default=0.5, ge=0, le=1, description="Minimum confidence for face detection")
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- mediapipe_face_processor = MediapipeFaceDetector()
- processed_image = mediapipe_face_processor(
- image,
- max_faces=self.max_faces,
- min_confidence=self.min_confidence,
- image_resolution=self.image_resolution,
- detect_resolution=self.detect_resolution,
- )
- return processed_image
-
-
-@invocation(
- "leres_image_processor",
- title="Leres (Depth) Processor",
- tags=["controlnet", "leres", "depth"],
- category="controlnet",
- version="1.2.3",
-)
-class LeresImageProcessorInvocation(ImageProcessorInvocation):
- """Applies leres processing to image"""
-
- thr_a: float = InputField(default=0, description="Leres parameter `thr_a`")
- thr_b: float = InputField(default=0, description="Leres parameter `thr_b`")
- boost: bool = InputField(default=False, description="Whether to use boost mode")
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- leres_processor = LeresDetector.from_pretrained("lllyasviel/Annotators")
- processed_image = leres_processor(
- image,
- thr_a=self.thr_a,
- thr_b=self.thr_b,
- boost=self.boost,
- detect_resolution=self.detect_resolution,
- image_resolution=self.image_resolution,
- )
- return processed_image
-
-
-@invocation(
- "tile_image_processor",
- title="Tile Resample Processor",
- tags=["controlnet", "tile"],
- category="controlnet",
- version="1.2.3",
-)
-class TileResamplerProcessorInvocation(ImageProcessorInvocation):
- """Tile resampler processor"""
-
- # res: int = InputField(default=512, ge=0, le=1024, description="The pixel resolution for each tile")
- down_sampling_rate: float = InputField(default=1.0, ge=1.0, le=8.0, description="Down sampling rate")
-
- # tile_resample copied from sd-webui-controlnet/scripts/processor.py
- def tile_resample(
- self,
- np_img: np.ndarray,
- res=512, # never used?
- down_sampling_rate=1.0,
- ):
- np_img = HWC3(np_img)
- if down_sampling_rate < 1.1:
- return np_img
- H, W, C = np_img.shape
- H = int(float(H) / float(down_sampling_rate))
- W = int(float(W) / float(down_sampling_rate))
- np_img = cv2.resize(np_img, (W, H), interpolation=cv2.INTER_AREA)
- return np_img
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- np_img = np.array(image, dtype=np.uint8)
- processed_np_image = self.tile_resample(
- np_img,
- # res=self.tile_size,
- down_sampling_rate=self.down_sampling_rate,
- )
- processed_image = Image.fromarray(processed_np_image)
- return processed_image
-
-
-@invocation(
- "segment_anything_processor",
- title="Segment Anything Processor",
- tags=["controlnet", "segmentanything"],
- category="controlnet",
- version="1.2.4",
-)
-class SegmentAnythingProcessorInvocation(ImageProcessorInvocation):
- """Applies segment anything processing to image"""
-
- detect_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.detect_res)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- # segment_anything_processor = SamDetector.from_pretrained("ybelkada/segment-anything", subfolder="checkpoints")
- segment_anything_processor = SamDetectorReproducibleColors.from_pretrained(
- "ybelkada/segment-anything", subfolder="checkpoints"
- )
- np_img = np.array(image, dtype=np.uint8)
- processed_image = segment_anything_processor(
- np_img, image_resolution=self.image_resolution, detect_resolution=self.detect_resolution
- )
- return processed_image
-
-
-class SamDetectorReproducibleColors(SamDetector):
- # overriding SamDetector.show_anns() method to use reproducible colors for segmentation image
- # base class show_anns() method randomizes colors,
- # which seems to also lead to non-reproducible image generation
- # so using ADE20k color palette instead
- def show_anns(self, anns: List[Dict]):
- if len(anns) == 0:
- return
- sorted_anns = sorted(anns, key=(lambda x: x["area"]), reverse=True)
- h, w = anns[0]["segmentation"].shape
- final_img = Image.fromarray(np.zeros((h, w, 3), dtype=np.uint8), mode="RGB")
- palette = ade_palette()
- for i, ann in enumerate(sorted_anns):
- m = ann["segmentation"]
- img = np.empty((m.shape[0], m.shape[1], 3), dtype=np.uint8)
- # doing modulo just in case number of annotated regions exceeds number of colors in palette
- ann_color = palette[i % len(palette)]
- img[:, :] = ann_color
- final_img.paste(Image.fromarray(img, mode="RGB"), (0, 0), Image.fromarray(np.uint8(m * 255)))
- return np.array(final_img, dtype=np.uint8)
-
-
-@invocation(
- "color_map_image_processor",
- title="Color Map Processor",
- tags=["controlnet"],
- category="controlnet",
- version="1.2.3",
-)
-class ColorMapImageProcessorInvocation(ImageProcessorInvocation):
- """Generates a color map from the provided image"""
-
- color_map_tile_size: int = InputField(default=64, ge=1, description=FieldDescriptions.tile_size)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- np_image = np.array(image, dtype=np.uint8)
- height, width = np_image.shape[:2]
-
- width_tile_size = min(self.color_map_tile_size, width)
- height_tile_size = min(self.color_map_tile_size, height)
-
- color_map = cv2.resize(
- np_image,
- (width // width_tile_size, height // height_tile_size),
- interpolation=cv2.INTER_CUBIC,
- )
- color_map = cv2.resize(color_map, (width, height), interpolation=cv2.INTER_NEAREST)
- color_map = Image.fromarray(color_map)
- return color_map
-
-
-DEPTH_ANYTHING_MODEL_SIZES = Literal["large", "base", "small"]
-
-
-@invocation(
- "depth_anything_image_processor",
- title="Depth Anything Processor",
- tags=["controlnet", "depth", "depth anything"],
- category="controlnet",
- version="1.1.2",
-)
-class DepthAnythingImageProcessorInvocation(ImageProcessorInvocation):
- """Generates a depth map based on the Depth Anything algorithm"""
-
- model_size: DEPTH_ANYTHING_MODEL_SIZES = InputField(
- default="small", description="The size of the depth model to use"
- )
- resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- def loader(model_path: Path):
- return DepthAnythingDetector.load_model(
- model_path, model_size=self.model_size, device=TorchDevice.choose_torch_device()
- )
-
- with self._context.models.load_remote_model(
- source=DEPTH_ANYTHING_MODELS[self.model_size], loader=loader
- ) as model:
- depth_anything_detector = DepthAnythingDetector(model, TorchDevice.choose_torch_device())
- processed_image = depth_anything_detector(image=image, resolution=self.resolution)
- return processed_image
-
-
-@invocation(
- "dw_openpose_image_processor",
- title="DW Openpose Image Processor",
- tags=["controlnet", "dwpose", "openpose"],
- category="controlnet",
- version="1.1.1",
-)
-class DWOpenposeImageProcessorInvocation(ImageProcessorInvocation):
- """Generates an openpose pose from an image using DWPose"""
-
- draw_body: bool = InputField(default=True)
- draw_face: bool = InputField(default=False)
- draw_hands: bool = InputField(default=False)
- image_resolution: int = InputField(default=512, ge=1, description=FieldDescriptions.image_res)
-
- def run_processor(self, image: Image.Image) -> Image.Image:
- onnx_det = self._context.models.download_and_cache_model(DWPOSE_MODELS["yolox_l.onnx"])
- onnx_pose = self._context.models.download_and_cache_model(DWPOSE_MODELS["dw-ll_ucoco_384.onnx"])
-
- dw_openpose = DWOpenposeDetector(onnx_det=onnx_det, onnx_pose=onnx_pose)
- processed_image = dw_openpose(
- image,
- draw_face=self.draw_face,
- draw_hands=self.draw_hands,
- draw_body=self.draw_body,
- resolution=self.image_resolution,
- )
- return processed_image
-
-
-@invocation(
- "heuristic_resize",
- title="Heuristic Resize",
- tags=["image, controlnet"],
- category="image",
- version="1.0.1",
- classification=Classification.Prototype,
-)
-class HeuristicResizeInvocation(BaseInvocation):
- """Resize an image using a heuristic method. Preserves edge maps."""
-
- image: ImageField = InputField(description="The image to resize")
- width: int = InputField(default=512, ge=1, description="The width to resize to (px)")
- height: int = InputField(default=512, ge=1, description="The height to resize to (px)")
-
- def invoke(self, context: InvocationContext) -> ImageOutput:
- image = context.images.get_pil(self.image.image_name, "RGB")
- np_img = pil_to_np(image)
- np_resized = heuristic_resize(np_img, (self.width, self.height))
- resized = np_to_pil(np_resized)
- image_dto = context.images.save(image=resized)
- return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/create_denoise_mask.py b/invokeai/app/invocations/create_denoise_mask.py
index 2d66c20dbd4..419a516bcdc 100644
--- a/invokeai/app/invocations/create_denoise_mask.py
+++ b/invokeai/app/invocations/create_denoise_mask.py
@@ -6,7 +6,6 @@
from torchvision.transforms.functional import resize as tv_resize
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-from invokeai.app.invocations.constants import DEFAULT_PRECISION
from invokeai.app.invocations.fields import FieldDescriptions, ImageField, Input, InputField
from invokeai.app.invocations.image_to_latents import ImageToLatentsInvocation
from invokeai.app.invocations.model import VAEField
@@ -19,7 +18,7 @@
"create_denoise_mask",
title="Create Denoise Mask",
tags=["mask", "denoise"],
- category="latents",
+ category="mask",
version="1.0.2",
)
class CreateDenoiseMaskInvocation(BaseInvocation):
@@ -29,11 +28,7 @@ class CreateDenoiseMaskInvocation(BaseInvocation):
image: Optional[ImageField] = InputField(default=None, description="Image which will be masked", ui_order=1)
mask: ImageField = InputField(description="The mask to use when pasting", ui_order=2)
tiled: bool = InputField(default=False, description=FieldDescriptions.tiled, ui_order=3)
- fp32: bool = InputField(
- default=DEFAULT_PRECISION == torch.float32,
- description=FieldDescriptions.fp32,
- ui_order=4,
- )
+ fp32: bool = InputField(default=False, description=FieldDescriptions.fp32, ui_order=4)
def prep_mask_tensor(self, mask_image: Image.Image) -> torch.Tensor:
if mask_image.mode != "L":
@@ -65,6 +60,7 @@ def invoke(self, context: InvocationContext) -> DenoiseMaskOutput:
img_mask = tv_resize(mask, image_tensor.shape[-2:], T.InterpolationMode.BILINEAR, antialias=False)
masked_image = image_tensor * torch.where(img_mask < 0.5, 0.0, 1.0)
# TODO:
+ context.util.signal_progress("Running VAE encoder")
masked_latents = ImageToLatentsInvocation.vae_encode(vae_info, self.fp32, self.tiled, masked_image.clone())
masked_latents_name = context.tensors.save(tensor=masked_latents)
diff --git a/invokeai/app/invocations/create_gradient_mask.py b/invokeai/app/invocations/create_gradient_mask.py
index 089313463bf..08826cc5efc 100644
--- a/invokeai/app/invocations/create_gradient_mask.py
+++ b/invokeai/app/invocations/create_gradient_mask.py
@@ -1,13 +1,14 @@
from typing import Literal, Optional
+import cv2
import numpy as np
import torch
import torchvision.transforms as T
-from PIL import Image, ImageFilter
+from PIL import Image
from torchvision.transforms.functional import resize as tv_resize
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
-from invokeai.app.invocations.constants import DEFAULT_PRECISION
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import (
DenoiseMaskField,
FieldDescriptions,
@@ -19,8 +20,7 @@
from invokeai.app.invocations.image_to_latents import ImageToLatentsInvocation
from invokeai.app.invocations.model import UNetField, VAEField
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager import LoadedModel
-from invokeai.backend.model_manager.config import MainConfigBase, ModelVariantType
+from invokeai.backend.model_manager.taxonomy import FluxVariantType, ModelType, ModelVariantType
from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
@@ -28,7 +28,10 @@
class GradientMaskOutput(BaseInvocationOutput):
"""Outputs a denoise mask and an image representing the total gradient of the mask."""
- denoise_mask: DenoiseMaskField = OutputField(description="Mask for denoise model run")
+ denoise_mask: DenoiseMaskField = OutputField(
+ description="Mask for denoise model run. Values of 0.0 represent the regions to be fully denoised, and 1.0 "
+ + "represent the regions to be preserved."
+ )
expanded_mask_area: ImageField = OutputField(
description="Image representing the total gradient area of the mask. For paste-back purposes."
)
@@ -38,16 +41,14 @@ class GradientMaskOutput(BaseInvocationOutput):
"create_gradient_mask",
title="Create Gradient Mask",
tags=["mask", "denoise"],
- category="latents",
- version="1.1.0",
+ category="mask",
+ version="1.3.0",
)
class CreateGradientMaskInvocation(BaseInvocation):
- """Creates mask for denoising model run."""
+ """Creates mask for denoising."""
- mask: ImageField = InputField(default=None, description="Image which will be masked", ui_order=1)
- edge_radius: int = InputField(
- default=16, ge=0, description="How far to blur/expand the edges of the mask", ui_order=2
- )
+ mask: ImageField = InputField(description="Image which will be masked", ui_order=1)
+ edge_radius: int = InputField(default=16, ge=0, description="How far to expand the edges of the mask", ui_order=2)
coherence_mode: Literal["Gaussian Blur", "Box Blur", "Staged"] = InputField(default="Gaussian Blur", ui_order=3)
minimum_denoise: float = InputField(
default=0.0, ge=0, le=1, description="Minimum denoise level for the coherence region", ui_order=4
@@ -73,60 +74,124 @@ class CreateGradientMaskInvocation(BaseInvocation):
ui_order=7,
)
tiled: bool = InputField(default=False, description=FieldDescriptions.tiled, ui_order=8)
- fp32: bool = InputField(
- default=DEFAULT_PRECISION == torch.float32,
- description=FieldDescriptions.fp32,
- ui_order=9,
- )
+ fp32: bool = InputField(default=False, description=FieldDescriptions.fp32, ui_order=9)
@torch.no_grad()
def invoke(self, context: InvocationContext) -> GradientMaskOutput:
mask_image = context.images.get_pil(self.mask.image_name, mode="L")
- if self.edge_radius > 0:
- if self.coherence_mode == "Box Blur":
- blur_mask = mask_image.filter(ImageFilter.BoxBlur(self.edge_radius))
- else: # Gaussian Blur OR Staged
- # Gaussian Blur uses standard deviation. 1/2 radius is a good approximation
- blur_mask = mask_image.filter(ImageFilter.GaussianBlur(self.edge_radius / 2))
- blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(blur_mask, normalize=False)
+ # Resize the mask_image. Makes the filter 64x faster and doesn't hurt quality in latent scale anyway
+ mask_image = mask_image.resize(
+ (
+ mask_image.width // LATENT_SCALE_FACTOR,
+ mask_image.height // LATENT_SCALE_FACTOR,
+ ),
+ resample=Image.Resampling.BILINEAR,
+ )
- # redistribute blur so that the original edges are 0 and blur outwards to 1
- blur_tensor = (blur_tensor - 0.5) * 2
+ mask_np_orig = np.array(mask_image, dtype=np.float32)
- threshold = 1 - self.minimum_denoise
+ self.edge_radius = self.edge_radius // LATENT_SCALE_FACTOR # scale the edge radius to match the mask size
+
+ if self.edge_radius > 0:
+ mask_np = 255 - mask_np_orig # invert so 0 is unmasked (higher values = higher denoise strength)
+ dilated_mask = mask_np.copy()
+
+ # Create kernel based on coherence mode
+ if self.coherence_mode == "Box Blur":
+ # Create a circular distance kernel that fades from center outward
+ kernel_size = self.edge_radius * 2 + 1
+ center = self.edge_radius
+ kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
+ for i in range(kernel_size):
+ for j in range(kernel_size):
+ dist = np.sqrt((i - center) ** 2 + (j - center) ** 2)
+ if dist <= self.edge_radius:
+ kernel[i, j] = 1.0 - (dist / self.edge_radius)
+ else: # Gaussian Blur or Staged
+ # Create a Gaussian kernel
+ kernel_size = self.edge_radius * 2 + 1
+ kernel = cv2.getGaussianKernel(
+ kernel_size, self.edge_radius / 2.5
+ ) # 2.5 is a magic number (standard deviation capturing)
+ kernel = kernel * kernel.T # Make 2D gaussian kernel
+ kernel = kernel / np.max(kernel) # Normalize center to 1.0
+
+ # Ensure values outside radius are 0
+ center = self.edge_radius
+ for i in range(kernel_size):
+ for j in range(kernel_size):
+ dist = np.sqrt((i - center) ** 2 + (j - center) ** 2)
+ if dist > self.edge_radius:
+ kernel[i, j] = 0
+
+ # 2D max filter
+ mask_tensor = torch.tensor(mask_np)
+ kernel_tensor = torch.tensor(kernel)
+ dilated_mask = 255 - self.max_filter2D_torch(mask_tensor, kernel_tensor).cpu()
+ dilated_mask = dilated_mask.numpy()
+
+ threshold = (1 - self.minimum_denoise) * 255
if self.coherence_mode == "Staged":
- # wherever the blur_tensor is less than fully masked, convert it to threshold
- blur_tensor = torch.where((blur_tensor < 1) & (blur_tensor > 0), threshold, blur_tensor)
- else:
- # wherever the blur_tensor is above threshold but less than 1, drop it to threshold
- blur_tensor = torch.where((blur_tensor > threshold) & (blur_tensor < 1), threshold, blur_tensor)
+ # wherever expanded mask is darker than the original mask but original was above threshhold, set it to the threshold
+ # makes any expansion areas drop to threshhold. Raising minimum across the image happen outside of this if
+ threshold_mask = (dilated_mask < mask_np_orig) & (mask_np_orig > threshold)
+ dilated_mask = np.where(threshold_mask, threshold, mask_np_orig)
+
+ # wherever expanded mask is less than 255 but greater than threshold, drop it to threshold (minimum denoise)
+ threshold_mask = (dilated_mask > threshold) & (dilated_mask < 255)
+ dilated_mask = np.where(threshold_mask, threshold, dilated_mask)
else:
- blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False)
+ dilated_mask = mask_np_orig.copy()
+
+ # convert to tensor
+ dilated_mask = np.clip(dilated_mask, 0, 255).astype(np.uint8)
+ mask_tensor = torch.tensor(dilated_mask, device=torch.device("cpu"))
+
+ # binary mask for compositing
+ expanded_mask = np.where((dilated_mask < 255), 0, 255)
+ expanded_mask_image = Image.fromarray(expanded_mask.astype(np.uint8), mode="L")
+ expanded_mask_image = expanded_mask_image.resize(
+ (
+ mask_image.width * LATENT_SCALE_FACTOR,
+ mask_image.height * LATENT_SCALE_FACTOR,
+ ),
+ resample=Image.Resampling.NEAREST,
+ )
+ expanded_image_dto = context.images.save(expanded_mask_image)
- mask_name = context.tensors.save(tensor=blur_tensor.unsqueeze(1))
+ # restore the original mask size
+ dilated_mask = Image.fromarray(dilated_mask.astype(np.uint8))
+ dilated_mask = dilated_mask.resize(
+ (
+ mask_image.width * LATENT_SCALE_FACTOR,
+ mask_image.height * LATENT_SCALE_FACTOR,
+ ),
+ resample=Image.Resampling.NEAREST,
+ )
- # compute a [0, 1] mask from the blur_tensor
- expanded_mask = torch.where((blur_tensor < 1), 0, 1)
- expanded_mask_image = Image.fromarray((expanded_mask.squeeze(0).numpy() * 255).astype(np.uint8), mode="L")
- expanded_image_dto = context.images.save(expanded_mask_image)
+ # stack the mask as a tensor, repeating 4 times on dimmension 1
+ dilated_mask_tensor = image_resized_to_grid_as_tensor(dilated_mask, normalize=False)
+ mask_name = context.tensors.save(tensor=dilated_mask_tensor.unsqueeze(0))
masked_latents_name = None
if self.unet is not None and self.vae is not None and self.image is not None:
# all three fields must be present at the same time
main_model_config = context.models.get_config(self.unet.unet.key)
- assert isinstance(main_model_config, MainConfigBase)
- if main_model_config.variant is ModelVariantType.Inpaint:
- mask = blur_tensor
- vae_info: LoadedModel = context.models.load(self.vae.vae)
+ assert main_model_config.type is ModelType.Main
+ variant = getattr(main_model_config, "variant", None)
+ if variant is ModelVariantType.Inpaint or variant is FluxVariantType.DevFill:
+ mask = dilated_mask_tensor
+ vae_info = context.models.load(self.vae.vae)
image = context.images.get_pil(self.image.image_name)
image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
if image_tensor.dim() == 3:
image_tensor = image_tensor.unsqueeze(0)
img_mask = tv_resize(mask, image_tensor.shape[-2:], T.InterpolationMode.BILINEAR, antialias=False)
masked_image = image_tensor * torch.where(img_mask < 0.5, 0.0, 1.0)
+ context.util.signal_progress("Running VAE encoder")
masked_latents = ImageToLatentsInvocation.vae_encode(
vae_info, self.fp32, self.tiled, masked_image.clone()
)
@@ -136,3 +201,29 @@ def invoke(self, context: InvocationContext) -> GradientMaskOutput:
denoise_mask=DenoiseMaskField(mask_name=mask_name, masked_latents_name=masked_latents_name, gradient=True),
expanded_mask_area=ImageField(image_name=expanded_image_dto.image_name),
)
+
+ def max_filter2D_torch(self, image: torch.Tensor, kernel: torch.Tensor) -> torch.Tensor:
+ """
+ This morphological operation is much faster in torch than numpy or opencv
+ For reasonable kernel sizes, the overhead of copying the data to the GPU is not worth it.
+ """
+ h, w = kernel.shape
+ pad_h, pad_w = h // 2, w // 2
+
+ padded = torch.nn.functional.pad(image, (pad_w, pad_w, pad_h, pad_h), mode="constant", value=0)
+ result = torch.zeros_like(image)
+
+ # This looks like it's inside out, but it does the same thing and is more efficient
+ for i in range(h):
+ for j in range(w):
+ weight = kernel[i, j]
+ if weight <= 0:
+ continue
+
+ # Extract the region from padded tensor
+ region = padded[i : i + image.shape[0], j : j + image.shape[1]]
+
+ # Apply weight and update max
+ result = torch.maximum(result, region * weight)
+
+ return result
diff --git a/invokeai/app/invocations/custom_nodes/init.py b/invokeai/app/invocations/custom_nodes/init.py
deleted file mode 100644
index efddede72fc..00000000000
--- a/invokeai/app/invocations/custom_nodes/init.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-Invoke-managed custom node loader. See README.md for more information.
-"""
-
-import sys
-import traceback
-from importlib.util import module_from_spec, spec_from_file_location
-from pathlib import Path
-
-from invokeai.backend.util.logging import InvokeAILogger
-
-logger = InvokeAILogger.get_logger()
-loaded_count = 0
-
-
-for d in Path(__file__).parent.iterdir():
- # skip files
- if not d.is_dir():
- continue
-
- # skip hidden directories
- if d.name.startswith("_") or d.name.startswith("."):
- continue
-
- # skip directories without an `__init__.py`
- init = d / "__init__.py"
- if not init.exists():
- continue
-
- module_name = init.parent.stem
-
- # skip if already imported
- if module_name in globals():
- continue
-
- # load the module, appending adding a suffix to identify it as a custom node pack
- spec = spec_from_file_location(module_name, init.absolute())
-
- if spec is None or spec.loader is None:
- logger.warn(f"Could not load {init}")
- continue
-
- logger.info(f"Loading node pack {module_name}")
-
- try:
- module = module_from_spec(spec)
- sys.modules[spec.name] = module
- spec.loader.exec_module(module)
-
- loaded_count += 1
- except Exception:
- full_error = traceback.format_exc()
- logger.error(f"Failed to load node pack {module_name}:\n{full_error}")
-
- del init, module_name
-
-if loaded_count > 0:
- logger.info(f"Loaded {loaded_count} node packs from {Path(__file__).parent}")
diff --git a/invokeai/app/invocations/cv.py b/invokeai/app/invocations/cv.py
index a7c394deb22..f7951ccfeb2 100644
--- a/invokeai/app/invocations/cv.py
+++ b/invokeai/app/invocations/cv.py
@@ -5,13 +5,11 @@
import numpy
from PIL import Image, ImageOps
-from invokeai.app.invocations.fields import ImageField
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField, WithBoard, WithMetadata
-
@invocation("cv_inpaint", title="OpenCV Inpaint", tags=["opencv", "inpaint"], category="inpaint", version="1.3.1")
class CvInpaintInvocation(BaseInvocation, WithMetadata, WithBoard):
diff --git a/invokeai/app/invocations/denoise_latents.py b/invokeai/app/invocations/denoise_latents.py
index e94daf70bdd..bb114263e23 100644
--- a/invokeai/app/invocations/denoise_latents.py
+++ b/invokeai/app/invocations/denoise_latents.py
@@ -1,5 +1,6 @@
# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654)
import inspect
+import os
from contextlib import ExitStack
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
@@ -9,16 +10,19 @@
from diffusers.configuration_utils import ConfigMixin
from diffusers.models.adapter import T2IAdapter
from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel
+from diffusers.schedulers.scheduling_dpmsolver_multistep import DPMSolverMultistepScheduler
from diffusers.schedulers.scheduling_dpmsolver_sde import DPMSolverSDEScheduler
+from diffusers.schedulers.scheduling_dpmsolver_singlestep import DPMSolverSinglestepScheduler
from diffusers.schedulers.scheduling_tcd import TCDScheduler
from diffusers.schedulers.scheduling_utils import SchedulerMixin as Scheduler
+from PIL import Image
from pydantic import field_validator
from torchvision.transforms.functional import resize as tv_resize
from transformers import CLIPVisionModelWithProjection
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR, SCHEDULER_NAME_VALUES
-from invokeai.app.invocations.controlnet_image_processors import ControlField
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.controlnet import ControlField
from invokeai.app.invocations.fields import (
ConditioningField,
DenoiseMaskField,
@@ -35,10 +39,13 @@
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import prepare_control_image
from invokeai.backend.ip_adapter.ip_adapter import IPAdapter
-from invokeai.backend.lora import LoRAModelRaw
-from invokeai.backend.model_manager import BaseModelType
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelVariantType
from invokeai.backend.model_patcher import ModelPatcher
-from invokeai.backend.stable_diffusion import PipelineIntermediateState, set_seamless
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext, DenoiseInputs
from invokeai.backend.stable_diffusion.diffusers_pipeline import (
ControlNetData,
StableDiffusionGeneratorPipeline,
@@ -53,8 +60,23 @@
TextConditioningData,
TextConditioningRegions,
)
+from invokeai.backend.stable_diffusion.diffusion.custom_atttention import CustomAttnProcessor2_0
+from invokeai.backend.stable_diffusion.diffusion_backend import StableDiffusionBackend
+from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType
+from invokeai.backend.stable_diffusion.extensions.controlnet import ControlNetExt
+from invokeai.backend.stable_diffusion.extensions.freeu import FreeUExt
+from invokeai.backend.stable_diffusion.extensions.inpaint import InpaintExt
+from invokeai.backend.stable_diffusion.extensions.inpaint_model import InpaintModelExt
+from invokeai.backend.stable_diffusion.extensions.lora import LoRAExt
+from invokeai.backend.stable_diffusion.extensions.preview import PreviewExt
+from invokeai.backend.stable_diffusion.extensions.rescale_cfg import RescaleCFGExt
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.stable_diffusion.extensions.t2i_adapter import T2IAdapterExt
+from invokeai.backend.stable_diffusion.extensions_manager import ExtensionsManager
from invokeai.backend.stable_diffusion.schedulers import SCHEDULER_MAP
+from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES
from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.hotfixes import ControlNetModel
from invokeai.backend.util.mask import to_standard_float_mask
from invokeai.backend.util.silence_warnings import SilenceWarnings
@@ -64,9 +86,14 @@ def get_scheduler(
scheduler_info: ModelIdentifierField,
scheduler_name: str,
seed: int,
+ unet_config: AnyModelConfig,
) -> Scheduler:
+ """Load a scheduler and apply some scheduler-specific overrides."""
+ # TODO(ryand): Silently falling back to ddim seems like a bad idea. Look into why this was added and remove if
+ # possible.
scheduler_class, scheduler_extra_config = SCHEDULER_MAP.get(scheduler_name, SCHEDULER_MAP["ddim"])
orig_scheduler_info = context.models.load(scheduler_info)
+
with orig_scheduler_info as orig_scheduler:
scheduler_config = orig_scheduler.config
@@ -78,10 +105,17 @@ def get_scheduler(
"_backup": scheduler_config,
}
+ if hasattr(unet_config, "prediction_type"):
+ scheduler_config["prediction_type"] = unet_config.prediction_type
+
# make dpmpp_sde reproducable(seed can be passed only in initializer)
if scheduler_class is DPMSolverSDEScheduler:
scheduler_config["noise_sampler_seed"] = seed
+ if scheduler_class is DPMSolverMultistepScheduler or scheduler_class is DPMSolverSinglestepScheduler:
+ if scheduler_config["_class_name"] == "DEISMultistepScheduler" and scheduler_config["algorithm_type"] == "deis":
+ scheduler_config["algorithm_type"] = "dpmsolver++"
+
scheduler = scheduler_class.from_config(scheduler_config)
# hack copied over from generate.py
@@ -93,10 +127,10 @@ def get_scheduler(
@invocation(
"denoise_latents",
- title="Denoise Latents",
+ title="Denoise - SD1.5, SDXL",
tags=["latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"],
category="latents",
- version="1.5.3",
+ version="1.5.4",
)
class DenoiseLatentsInvocation(BaseInvocation):
"""Denoises noisy latents to decodable images"""
@@ -165,7 +199,7 @@ class DenoiseLatentsInvocation(BaseInvocation):
)
denoise_mask: Optional[DenoiseMaskField] = InputField(
default=None,
- description=FieldDescriptions.mask,
+ description=FieldDescriptions.denoise_mask,
input=Input.Connection,
ui_order=8,
)
@@ -182,8 +216,8 @@ def ge_one(cls, v: Union[List[float], float]) -> Union[List[float], float]:
raise ValueError("cfg_scale must be greater than 1")
return v
+ @staticmethod
def _get_text_embeddings_and_masks(
- self,
cond_list: list[ConditioningField],
context: InvocationContext,
device: torch.device,
@@ -203,8 +237,9 @@ def _get_text_embeddings_and_masks(
return text_embeddings, text_embeddings_masks
+ @staticmethod
def _preprocess_regional_prompt_mask(
- self, mask: Optional[torch.Tensor], target_height: int, target_width: int, dtype: torch.dtype
+ mask: Optional[torch.Tensor], target_height: int, target_width: int, dtype: torch.dtype
) -> torch.Tensor:
"""Preprocess a regional prompt mask to match the target height and width.
If mask is None, returns a mask of all ones with the target height and width.
@@ -228,8 +263,8 @@ def _preprocess_regional_prompt_mask(
resized_mask = tf(mask)
return resized_mask
+ @staticmethod
def _concat_regional_text_embeddings(
- self,
text_conditionings: Union[list[BasicConditioningInfo], list[SDXLConditioningInfo]],
masks: Optional[list[Optional[torch.Tensor]]],
latent_height: int,
@@ -279,7 +314,9 @@ def _concat_regional_text_embeddings(
)
)
processed_masks.append(
- self._preprocess_regional_prompt_mask(mask, latent_height, latent_width, dtype=dtype)
+ DenoiseLatentsInvocation._preprocess_regional_prompt_mask(
+ mask, latent_height, latent_width, dtype=dtype
+ )
)
cur_text_embedding_len += text_embedding_info.embeds.shape[1]
@@ -301,60 +338,64 @@ def _concat_regional_text_embeddings(
)
return BasicConditioningInfo(embeds=text_embedding), regions
+ @staticmethod
def get_conditioning_data(
- self,
context: InvocationContext,
- unet: UNet2DConditionModel,
+ positive_conditioning_field: Union[ConditioningField, list[ConditioningField]],
+ negative_conditioning_field: Union[ConditioningField, list[ConditioningField]],
latent_height: int,
latent_width: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ cfg_scale: float | list[float],
+ steps: int,
+ cfg_rescale_multiplier: float,
) -> TextConditioningData:
- # Normalize self.positive_conditioning and self.negative_conditioning to lists.
- cond_list = self.positive_conditioning
+ # Normalize positive_conditioning_field and negative_conditioning_field to lists.
+ cond_list = positive_conditioning_field
if not isinstance(cond_list, list):
cond_list = [cond_list]
- uncond_list = self.negative_conditioning
+ uncond_list = negative_conditioning_field
if not isinstance(uncond_list, list):
uncond_list = [uncond_list]
- cond_text_embeddings, cond_text_embedding_masks = self._get_text_embeddings_and_masks(
- cond_list, context, unet.device, unet.dtype
+ cond_text_embeddings, cond_text_embedding_masks = DenoiseLatentsInvocation._get_text_embeddings_and_masks(
+ cond_list, context, device, dtype
)
- uncond_text_embeddings, uncond_text_embedding_masks = self._get_text_embeddings_and_masks(
- uncond_list, context, unet.device, unet.dtype
+ uncond_text_embeddings, uncond_text_embedding_masks = DenoiseLatentsInvocation._get_text_embeddings_and_masks(
+ uncond_list, context, device, dtype
)
- cond_text_embedding, cond_regions = self._concat_regional_text_embeddings(
+ cond_text_embedding, cond_regions = DenoiseLatentsInvocation._concat_regional_text_embeddings(
text_conditionings=cond_text_embeddings,
masks=cond_text_embedding_masks,
latent_height=latent_height,
latent_width=latent_width,
- dtype=unet.dtype,
+ dtype=dtype,
)
- uncond_text_embedding, uncond_regions = self._concat_regional_text_embeddings(
+ uncond_text_embedding, uncond_regions = DenoiseLatentsInvocation._concat_regional_text_embeddings(
text_conditionings=uncond_text_embeddings,
masks=uncond_text_embedding_masks,
latent_height=latent_height,
latent_width=latent_width,
- dtype=unet.dtype,
+ dtype=dtype,
)
- if isinstance(self.cfg_scale, list):
- assert (
- len(self.cfg_scale) == self.steps
- ), "cfg_scale (list) must have the same length as the number of steps"
+ if isinstance(cfg_scale, list):
+ assert len(cfg_scale) == steps, "cfg_scale (list) must have the same length as the number of steps"
conditioning_data = TextConditioningData(
uncond_text=uncond_text_embedding,
cond_text=cond_text_embedding,
uncond_regions=uncond_regions,
cond_regions=cond_regions,
- guidance_scale=self.cfg_scale,
- guidance_rescale_multiplier=self.cfg_rescale_multiplier,
+ guidance_scale=cfg_scale,
+ guidance_rescale_multiplier=cfg_rescale_multiplier,
)
return conditioning_data
+ @staticmethod
def create_pipeline(
- self,
unet: UNet2DConditionModel,
scheduler: Scheduler,
) -> StableDiffusionGeneratorPipeline:
@@ -377,38 +418,39 @@ def __init__(self) -> None:
requires_safety_checker=False,
)
+ @staticmethod
def prep_control_data(
- self,
context: InvocationContext,
- control_input: Optional[Union[ControlField, List[ControlField]]],
+ control_input: ControlField | list[ControlField] | None,
latents_shape: List[int],
+ device: torch.device,
exit_stack: ExitStack,
do_classifier_free_guidance: bool = True,
- ) -> Optional[List[ControlNetData]]:
- # Assuming fixed dimensional scaling of LATENT_SCALE_FACTOR.
- control_height_resize = latents_shape[2] * LATENT_SCALE_FACTOR
- control_width_resize = latents_shape[3] * LATENT_SCALE_FACTOR
- if control_input is None:
- control_list = None
- elif isinstance(control_input, list) and len(control_input) == 0:
- control_list = None
- elif isinstance(control_input, ControlField):
+ ) -> list[ControlNetData] | None:
+ # Normalize control_input to a list.
+ control_list: list[ControlField]
+ if isinstance(control_input, ControlField):
control_list = [control_input]
- elif isinstance(control_input, list) and len(control_input) > 0 and isinstance(control_input[0], ControlField):
+ elif isinstance(control_input, list):
control_list = control_input
+ elif control_input is None:
+ control_list = []
else:
- control_list = None
- if control_list is None:
+ raise ValueError(f"Unexpected control_input type: {type(control_input)}")
+
+ if len(control_list) == 0:
return None
- # After above handling, any control that is not None should now be of type list[ControlField].
- # FIXME: add checks to skip entry if model or image is None
- # and if weight is None, populate with default 1.0?
- controlnet_data = []
+ # Assuming fixed dimensional scaling of LATENT_SCALE_FACTOR.
+ _, _, latent_height, latent_width = latents_shape
+ control_height_resize = latent_height * LATENT_SCALE_FACTOR
+ control_width_resize = latent_width * LATENT_SCALE_FACTOR
+
+ controlnet_data: list[ControlNetData] = []
for control_info in control_list:
control_model = exit_stack.enter_context(context.models.load(control_info.control_model))
+ assert isinstance(control_model, ControlNetModel)
- # control_models.append(control_model)
control_image_field = control_info.image
input_image = context.images.get_pil(control_image_field.image_name)
# self.image.image_type, self.image.image_name
@@ -423,13 +465,13 @@ def prep_control_data(
height=control_height_resize,
# batch_size=batch_size * num_images_per_prompt,
# num_images_per_prompt=num_images_per_prompt,
- device=control_model.device,
+ device=device,
dtype=control_model.dtype,
control_mode=control_info.control_mode,
resize_mode=control_info.resize_mode,
)
control_item = ControlNetData(
- model=control_model, # model object
+ model=control_model,
image_tensor=control_image,
weight=control_info.control_weight,
begin_step_percent=control_info.begin_step_percent,
@@ -444,6 +486,70 @@ def prep_control_data(
return controlnet_data
+ @staticmethod
+ def parse_controlnet_field(
+ exit_stack: ExitStack,
+ context: InvocationContext,
+ control_input: ControlField | list[ControlField] | None,
+ ext_manager: ExtensionsManager,
+ ) -> None:
+ # Normalize control_input to a list.
+ control_list: list[ControlField]
+ if isinstance(control_input, ControlField):
+ control_list = [control_input]
+ elif isinstance(control_input, list):
+ control_list = control_input
+ elif control_input is None:
+ control_list = []
+ else:
+ raise ValueError(f"Unexpected control_input type: {type(control_input)}")
+
+ for control_info in control_list:
+ model = exit_stack.enter_context(context.models.load(control_info.control_model))
+ ext_manager.add_extension(
+ ControlNetExt(
+ model=model,
+ image=context.images.get_pil(control_info.image.image_name),
+ weight=control_info.control_weight,
+ begin_step_percent=control_info.begin_step_percent,
+ end_step_percent=control_info.end_step_percent,
+ control_mode=control_info.control_mode,
+ resize_mode=control_info.resize_mode,
+ )
+ )
+
+ @staticmethod
+ def parse_t2i_adapter_field(
+ exit_stack: ExitStack,
+ context: InvocationContext,
+ t2i_adapters: Optional[Union[T2IAdapterField, list[T2IAdapterField]]],
+ ext_manager: ExtensionsManager,
+ bgr_mode: bool = False,
+ ) -> None:
+ if t2i_adapters is None:
+ return
+
+ # Handle the possibility that t2i_adapters could be a list or a single T2IAdapterField.
+ if isinstance(t2i_adapters, T2IAdapterField):
+ t2i_adapters = [t2i_adapters]
+
+ for t2i_adapter_field in t2i_adapters:
+ image = context.images.get_pil(t2i_adapter_field.image.image_name)
+ if bgr_mode: # SDXL t2i trained on cv2's BGR outputs, but PIL won't convert straight to BGR
+ r, g, b = image.split()
+ image = Image.merge("RGB", (b, g, r))
+ ext_manager.add_extension(
+ T2IAdapterExt(
+ node_context=context,
+ model_id=t2i_adapter_field.t2i_adapter_model,
+ image=context.images.get_pil(t2i_adapter_field.image.image_name),
+ weight=t2i_adapter_field.weight,
+ begin_step_percent=t2i_adapter_field.begin_step_percent,
+ end_step_percent=t2i_adapter_field.end_step_percent,
+ resize_mode=t2i_adapter_field.resize_mode,
+ )
+ )
+
def prep_ip_adapter_image_prompts(
self,
context: InvocationContext,
@@ -454,14 +560,15 @@ def prep_ip_adapter_image_prompts(
for single_ip_adapter in ip_adapters:
with context.models.load(single_ip_adapter.ip_adapter_model) as ip_adapter_model:
assert isinstance(ip_adapter_model, IPAdapter)
- image_encoder_model_info = context.models.load(single_ip_adapter.image_encoder_model)
# `single_ip_adapter.image` could be a list or a single ImageField. Normalize to a list here.
single_ipa_image_fields = single_ip_adapter.image
if not isinstance(single_ipa_image_fields, list):
single_ipa_image_fields = [single_ipa_image_fields]
- single_ipa_images = [context.images.get_pil(image.image_name) for image in single_ipa_image_fields]
- with image_encoder_model_info as image_encoder_model:
+ single_ipa_images = [
+ context.images.get_pil(image.image_name, mode="RGB") for image in single_ipa_image_fields
+ ]
+ with context.models.load(single_ip_adapter.image_encoder_model) as image_encoder_model:
assert isinstance(image_encoder_model, CLIPVisionModelWithProjection)
# Get image embeddings from CLIP and ImageProjModel.
image_prompt_embeds, uncond_image_prompt_embeds = ip_adapter_model.get_image_embeds(
@@ -501,6 +608,7 @@ def prep_ip_adapter_data(
end_step_percent=single_ip_adapter.end_step_percent,
ip_adapter_conditioning=IPAdapterConditioningInfo(image_prompt_embeds, uncond_image_prompt_embeds),
mask=mask,
+ method=single_ip_adapter.method,
)
)
@@ -511,6 +619,7 @@ def run_t2i_adapters(
context: InvocationContext,
t2i_adapter: Optional[Union[T2IAdapterField, list[T2IAdapterField]]],
latents_shape: list[int],
+ device: torch.device,
do_classifier_free_guidance: bool,
) -> Optional[list[T2IAdapterData]]:
if t2i_adapter is None:
@@ -526,44 +635,57 @@ def run_t2i_adapters(
t2i_adapter_data = []
for t2i_adapter_field in t2i_adapter:
t2i_adapter_model_config = context.models.get_config(t2i_adapter_field.t2i_adapter_model.key)
- t2i_adapter_loaded_model = context.models.load(t2i_adapter_field.t2i_adapter_model)
- image = context.images.get_pil(t2i_adapter_field.image.image_name)
+ image = context.images.get_pil(t2i_adapter_field.image.image_name, mode="RGB")
# The max_unet_downscale is the maximum amount that the UNet model downscales the latent image internally.
if t2i_adapter_model_config.base == BaseModelType.StableDiffusion1:
max_unet_downscale = 8
elif t2i_adapter_model_config.base == BaseModelType.StableDiffusionXL:
max_unet_downscale = 4
+
+ # SDXL adapters are trained on cv2's BGR outputs
+ r, g, b = image.split()
+ image = Image.merge("RGB", (b, g, r))
else:
raise ValueError(f"Unexpected T2I-Adapter base model type: '{t2i_adapter_model_config.base}'.")
t2i_adapter_model: T2IAdapter
- with t2i_adapter_loaded_model as t2i_adapter_model:
+ with context.models.load(t2i_adapter_field.t2i_adapter_model) as t2i_adapter_model:
total_downscale_factor = t2i_adapter_model.total_downscale_factor
- # Resize the T2I-Adapter input image.
- # We select the resize dimensions so that after the T2I-Adapter's total_downscale_factor is applied, the
- # result will match the latent image's dimensions after max_unet_downscale is applied.
- t2i_input_height = latents_shape[2] // max_unet_downscale * total_downscale_factor
- t2i_input_width = latents_shape[3] // max_unet_downscale * total_downscale_factor
-
# Note: We have hard-coded `do_classifier_free_guidance=False`. This is because we only want to prepare
# a single image. If CFG is enabled, we will duplicate the resultant tensor after applying the
# T2I-Adapter model.
#
# Note: We re-use the `prepare_control_image(...)` from ControlNet for T2I-Adapter, because it has many
# of the same requirements (e.g. preserving binary masks during resize).
+
+ # Assuming fixed dimensional scaling of LATENT_SCALE_FACTOR.
+ _, _, latent_height, latent_width = latents_shape
+ control_height_resize = latent_height * LATENT_SCALE_FACTOR
+ control_width_resize = latent_width * LATENT_SCALE_FACTOR
t2i_image = prepare_control_image(
image=image,
do_classifier_free_guidance=False,
- width=t2i_input_width,
- height=t2i_input_height,
+ width=control_width_resize,
+ height=control_height_resize,
num_channels=t2i_adapter_model.config["in_channels"], # mypy treats this as a FrozenDict
- device=t2i_adapter_model.device,
+ device=device,
dtype=t2i_adapter_model.dtype,
resize_mode=t2i_adapter_field.resize_mode,
)
+ # Resize the T2I-Adapter input image.
+ # We select the resize dimensions so that after the T2I-Adapter's total_downscale_factor is applied, the
+ # result will match the latent image's dimensions after max_unet_downscale is applied.
+ # We crop the image to this size so that the positions match the input image on non-standard resolutions
+ t2i_input_height = latents_shape[2] // max_unet_downscale * total_downscale_factor
+ t2i_input_width = latents_shape[3] // max_unet_downscale * total_downscale_factor
+ if t2i_image.shape[2] > t2i_input_height or t2i_image.shape[3] > t2i_input_width:
+ t2i_image = t2i_image[
+ :, :, : min(t2i_image.shape[2], t2i_input_height), : min(t2i_image.shape[3], t2i_input_width)
+ ]
+
adapter_state = t2i_adapter_model(t2i_image)
if do_classifier_free_guidance:
@@ -583,15 +705,15 @@ def run_t2i_adapters(
# original idea by https://github.com/AmericanPresidentJimmyCarter
# TODO: research more for second order schedulers timesteps
+ @staticmethod
def init_scheduler(
- self,
scheduler: Union[Scheduler, ConfigMixin],
device: torch.device,
steps: int,
denoising_start: float,
denoising_end: float,
seed: int,
- ) -> Tuple[int, List[int], int, Dict[str, Any]]:
+ ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, Any]]:
assert isinstance(scheduler, ConfigMixin)
if scheduler.config.get("cpu_only", False):
scheduler.set_timesteps(steps, device="cpu")
@@ -617,7 +739,6 @@ def init_scheduler(
init_timestep = timesteps[t_start_idx : t_start_idx + 1]
timesteps = timesteps[t_start_idx : t_start_idx + t_end_idx]
- num_inference_steps = len(timesteps) // scheduler.order
scheduler_step_kwargs: Dict[str, Any] = {}
scheduler_step_signature = inspect.signature(scheduler.step)
@@ -639,7 +760,7 @@ def init_scheduler(
if isinstance(scheduler, TCDScheduler):
scheduler_step_kwargs.update({"eta": 1.0})
- return num_inference_steps, timesteps, init_timestep, scheduler_step_kwargs
+ return timesteps, init_timestep, scheduler_step_kwargs
def prep_inpaint_mask(
self, context: InvocationContext, latents: torch.Tensor
@@ -654,34 +775,201 @@ def prep_inpaint_mask(
else:
masked_latents = torch.where(mask < 0.5, 0.0, latents)
- return 1 - mask, masked_latents, self.denoise_mask.gradient
+ return mask, masked_latents, self.denoise_mask.gradient
- @torch.no_grad()
- @SilenceWarnings() # This quenches the NSFW nag from diffusers.
- def invoke(self, context: InvocationContext) -> LatentsOutput:
- seed = None
- noise = None
- if self.noise is not None:
- noise = context.tensors.load(self.noise.latents_name)
- seed = self.noise.seed
+ @staticmethod
+ def prepare_noise_and_latents(
+ context: InvocationContext, noise_field: LatentsField | None, latents_field: LatentsField | None
+ ) -> Tuple[int, torch.Tensor | None, torch.Tensor]:
+ """Depending on the workflow, we expect different combinations of noise and latents to be provided. This
+ function handles preparing these values accordingly.
- if self.latents is not None:
- latents = context.tensors.load(self.latents.latents_name)
- if seed is None:
- seed = self.latents.seed
+ Expected workflows:
+ - Text-to-Image Denoising: `noise` is provided, `latents` is not. `latents` is initialized to zeros.
+ - Image-to-Image Denoising: `noise` and `latents` are both provided.
+ - Text-to-Image SDXL Refiner Denoising: `latents` is provided, `noise` is not.
+ - Image-to-Image SDXL Refiner Denoising: `latents` is provided, `noise` is not.
- if noise is not None and noise.shape[1:] != latents.shape[1:]:
- raise Exception(f"Incompatable 'noise' and 'latents' shapes: {latents.shape=} {noise.shape=}")
+ NOTE(ryand): I wrote this docstring, but I am not the original author of this code. There may be other workflows
+ I haven't considered.
+ """
+ noise = None
+ if noise_field is not None:
+ noise = context.tensors.load(noise_field.latents_name)
+ if latents_field is not None:
+ latents = context.tensors.load(latents_field.latents_name)
elif noise is not None:
latents = torch.zeros_like(noise)
else:
- raise Exception("'latents' or 'noise' must be provided!")
+ raise ValueError("'latents' or 'noise' must be provided!")
- if seed is None:
+ if noise is not None and noise.shape[1:] != latents.shape[1:]:
+ raise ValueError(f"Incompatible 'noise' and 'latents' shapes: {latents.shape=} {noise.shape=}")
+
+ # The seed comes from (in order of priority): the noise field, the latents field, or 0.
+ seed = 0
+ if noise_field is not None and noise_field.seed is not None:
+ seed = noise_field.seed
+ elif latents_field is not None and latents_field.seed is not None:
+ seed = latents_field.seed
+ else:
seed = 0
+ return seed, noise, latents
+
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ if os.environ.get("USE_MODULAR_DENOISE", False):
+ return self._new_invoke(context)
+ else:
+ return self._old_invoke(context)
+
+ @torch.no_grad()
+ @SilenceWarnings() # This quenches the NSFW nag from diffusers.
+ def _new_invoke(self, context: InvocationContext) -> LatentsOutput:
+ ext_manager = ExtensionsManager(is_canceled=context.util.is_canceled)
+
+ device = TorchDevice.choose_torch_device()
+ dtype = TorchDevice.choose_torch_dtype()
+
+ seed, noise, latents = self.prepare_noise_and_latents(context, self.noise, self.latents)
+ _, _, latent_height, latent_width = latents.shape
+
+ # get the unet's config so that we can pass the base to sd_step_callback()
+ unet_config = context.models.get_config(self.unet.unet.key)
+
+ conditioning_data = self.get_conditioning_data(
+ context=context,
+ positive_conditioning_field=self.positive_conditioning,
+ negative_conditioning_field=self.negative_conditioning,
+ cfg_scale=self.cfg_scale,
+ steps=self.steps,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ device=device,
+ dtype=dtype,
+ # TODO: old backend, remove
+ cfg_rescale_multiplier=self.cfg_rescale_multiplier,
+ )
+
+ scheduler = get_scheduler(
+ context=context,
+ scheduler_info=self.unet.scheduler,
+ scheduler_name=self.scheduler,
+ seed=seed,
+ unet_config=unet_config,
+ )
+
+ timesteps, init_timestep, scheduler_step_kwargs = self.init_scheduler(
+ scheduler,
+ seed=seed,
+ device=device,
+ steps=self.steps,
+ denoising_start=self.denoising_start,
+ denoising_end=self.denoising_end,
+ )
+
+ ### preview
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, unet_config.base)
+
+ ext_manager.add_extension(PreviewExt(step_callback))
+
+ ### cfg rescale
+ if self.cfg_rescale_multiplier > 0:
+ ext_manager.add_extension(RescaleCFGExt(self.cfg_rescale_multiplier))
+
+ ### freeu
+ if self.unet.freeu_config:
+ ext_manager.add_extension(FreeUExt(self.unet.freeu_config))
+
+ ### lora
+ if self.unet.loras:
+ for lora_field in self.unet.loras:
+ ext_manager.add_extension(
+ LoRAExt(
+ node_context=context,
+ model_id=lora_field.lora,
+ weight=lora_field.weight,
+ )
+ )
+ ### seamless
+ if self.unet.seamless_axes:
+ ext_manager.add_extension(SeamlessExt(self.unet.seamless_axes))
+
+ ### inpaint
+ mask, masked_latents, is_gradient_mask = self.prep_inpaint_mask(context, latents)
+ # NOTE: We used to identify inpainting models by inspecting the shape of the loaded UNet model weights. Now we
+ # use the ModelVariantType config. During testing, there was a report of a user with models that had an
+ # incorrect ModelVariantType value. Re-installing the model fixed the issue. If this issue turns out to be
+ # prevalent, we will have to revisit how we initialize the inpainting extensions.
+ if unet_config.variant == ModelVariantType.Inpaint:
+ ext_manager.add_extension(InpaintModelExt(mask, masked_latents, is_gradient_mask))
+ elif mask is not None:
+ ext_manager.add_extension(InpaintExt(mask, is_gradient_mask))
+
+ # Initialize context for modular denoise
+ latents = latents.to(device=device, dtype=dtype)
+ if noise is not None:
+ noise = noise.to(device=device, dtype=dtype)
+ denoise_ctx = DenoiseContext(
+ inputs=DenoiseInputs(
+ orig_latents=latents,
+ timesteps=timesteps,
+ init_timestep=init_timestep,
+ noise=noise,
+ seed=seed,
+ scheduler_step_kwargs=scheduler_step_kwargs,
+ conditioning_data=conditioning_data,
+ attention_processor_cls=CustomAttnProcessor2_0,
+ ),
+ unet=None,
+ scheduler=scheduler,
+ )
+
+ # context for loading additional models
+ with ExitStack() as exit_stack:
+ # later should be smth like:
+ # for extension_field in self.extensions:
+ # ext = extension_field.to_extension(exit_stack, context, ext_manager)
+ # ext_manager.add_extension(ext)
+ self.parse_controlnet_field(exit_stack, context, self.control, ext_manager)
+ bgr_mode = self.unet.unet.base == BaseModelType.StableDiffusionXL
+ self.parse_t2i_adapter_field(exit_stack, context, self.t2i_adapter, ext_manager, bgr_mode)
+
+ # ext: t2i/ip adapter
+ ext_manager.run_callback(ExtensionCallbackType.SETUP, denoise_ctx)
+
+ with (
+ context.models.load(self.unet.unet).model_on_device() as (cached_weights, unet),
+ ModelPatcher.patch_unet_attention_processor(unet, denoise_ctx.inputs.attention_processor_cls),
+ # ext: controlnet
+ ext_manager.patch_extensions(denoise_ctx),
+ # ext: freeu, seamless, ip adapter, lora
+ ext_manager.patch_unet(unet, cached_weights),
+ ):
+ sd_backend = StableDiffusionBackend(unet, scheduler)
+ denoise_ctx.unet = unet
+ result_latents = sd_backend.latents_from_embeddings(denoise_ctx, ext_manager)
+
+ # https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699
+ result_latents = result_latents.detach().to("cpu")
+ TorchDevice.empty_cache()
+
+ name = context.tensors.save(tensor=result_latents)
+ return LatentsOutput.build(latents_name=name, latents=result_latents, seed=None)
+
+ @torch.no_grad()
+ @SilenceWarnings() # This quenches the NSFW nag from diffusers.
+ def _old_invoke(self, context: InvocationContext) -> LatentsOutput:
+ device = TorchDevice.choose_torch_device()
+ seed, noise, latents = self.prepare_noise_and_latents(context, self.noise, self.latents)
+
mask, masked_latents, gradient_mask = self.prep_inpaint_mask(context, latents)
+ # At this point, the mask ranges from 0 (leave unchanged) to 1 (inpaint).
+ # We invert the mask here for compatibility with the old backend implementation.
+ if mask is not None:
+ mask = 1 - mask
# TODO(ryand): I have hard-coded `do_classifier_free_guidance=True` to mirror the behaviour of ControlNets,
# below. Investigate whether this is appropriate.
@@ -689,6 +977,7 @@ def invoke(self, context: InvocationContext) -> LatentsOutput:
context,
self.t2i_adapter,
latents.shape,
+ device=device,
do_classifier_free_guidance=True,
)
@@ -706,61 +995,72 @@ def invoke(self, context: InvocationContext) -> LatentsOutput:
# The image prompts are then passed to prep_ip_adapter_data().
image_prompts = self.prep_ip_adapter_image_prompts(context=context, ip_adapters=ip_adapters)
- # get the unet's config so that we can pass the base to dispatch_progress()
+ # get the unet's config so that we can pass the base to sd_step_callback()
unet_config = context.models.get_config(self.unet.unet.key)
def step_callback(state: PipelineIntermediateState) -> None:
context.util.sd_step_callback(state, unet_config.base)
- def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
+ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
for lora in self.unet.loras:
lora_info = context.models.load(lora.lora)
- assert isinstance(lora_info.model, LoRAModelRaw)
+ assert isinstance(lora_info.model, ModelPatchRaw)
yield (lora_info.model, lora.weight)
del lora_info
return
- unet_info = context.models.load(self.unet.unet)
- assert isinstance(unet_info.model, UNet2DConditionModel)
with (
ExitStack() as exit_stack,
- unet_info.model_on_device() as (model_state_dict, unet),
+ context.models.load(self.unet.unet).model_on_device() as (cached_weights, unet),
ModelPatcher.apply_freeu(unet, self.unet.freeu_config),
- set_seamless(unet, self.unet.seamless_axes), # FIXME
+ SeamlessExt.static_patch_model(unet, self.unet.seamless_axes), # FIXME
# Apply the LoRA after unet has been moved to its target device for faster patching.
- ModelPatcher.apply_lora_unet(
- unet,
- loras=_lora_loader(),
- model_state_dict=model_state_dict,
+ LayerPatcher.apply_smart_model_patches(
+ model=unet,
+ patches=_lora_loader(),
+ prefix="lora_unet_",
+ dtype=unet.dtype,
+ cached_weights=cached_weights,
),
):
assert isinstance(unet, UNet2DConditionModel)
- latents = latents.to(device=unet.device, dtype=unet.dtype)
+ latents = latents.to(device=device, dtype=unet.dtype)
if noise is not None:
- noise = noise.to(device=unet.device, dtype=unet.dtype)
+ noise = noise.to(device=device, dtype=unet.dtype)
if mask is not None:
- mask = mask.to(device=unet.device, dtype=unet.dtype)
+ mask = mask.to(device=device, dtype=unet.dtype)
if masked_latents is not None:
- masked_latents = masked_latents.to(device=unet.device, dtype=unet.dtype)
+ masked_latents = masked_latents.to(device=device, dtype=unet.dtype)
scheduler = get_scheduler(
context=context,
scheduler_info=self.unet.scheduler,
scheduler_name=self.scheduler,
seed=seed,
+ unet_config=unet_config,
)
pipeline = self.create_pipeline(unet, scheduler)
_, _, latent_height, latent_width = latents.shape
conditioning_data = self.get_conditioning_data(
- context=context, unet=unet, latent_height=latent_height, latent_width=latent_width
+ context=context,
+ positive_conditioning_field=self.positive_conditioning,
+ negative_conditioning_field=self.negative_conditioning,
+ device=device,
+ dtype=unet.dtype,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ cfg_scale=self.cfg_scale,
+ steps=self.steps,
+ cfg_rescale_multiplier=self.cfg_rescale_multiplier,
)
controlnet_data = self.prep_control_data(
context=context,
control_input=self.control,
latents_shape=latents.shape,
+ device=device,
# do_classifier_free_guidance=(self.cfg_scale >= 1.0))
do_classifier_free_guidance=True,
exit_stack=exit_stack,
@@ -776,9 +1076,9 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
dtype=unet.dtype,
)
- num_inference_steps, timesteps, init_timestep, scheduler_step_kwargs = self.init_scheduler(
+ timesteps, init_timestep, scheduler_step_kwargs = self.init_scheduler(
scheduler,
- device=unet.device,
+ device=device,
steps=self.steps,
denoising_start=self.denoising_start,
denoising_end=self.denoising_end,
@@ -793,8 +1093,7 @@ def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]:
seed=seed,
mask=mask,
masked_latents=masked_latents,
- gradient_mask=gradient_mask,
- num_inference_steps=num_inference_steps,
+ is_gradient_mask=gradient_mask,
scheduler_step_kwargs=scheduler_step_kwargs,
conditioning_data=conditioning_data,
control_data=controlnet_data,
diff --git a/invokeai/app/invocations/depth_anything.py b/invokeai/app/invocations/depth_anything.py
new file mode 100644
index 00000000000..1fd808efde5
--- /dev/null
+++ b/invokeai/app/invocations/depth_anything.py
@@ -0,0 +1,45 @@
+from typing import Literal
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.depth_anything.depth_anything_pipeline import DepthAnythingPipeline
+
+DEPTH_ANYTHING_MODEL_SIZES = Literal["large", "base", "small", "small_v2"]
+# DepthAnything V2 Small model is licensed under Apache 2.0 but not the base and large models.
+DEPTH_ANYTHING_MODELS = {
+ "large": "LiheYoung/depth-anything-large-hf",
+ "base": "LiheYoung/depth-anything-base-hf",
+ "small": "LiheYoung/depth-anything-small-hf",
+ "small_v2": "depth-anything/Depth-Anything-V2-Small-hf",
+}
+
+
+@invocation(
+ "depth_anything_depth_estimation",
+ title="Depth Anything Depth Estimation",
+ tags=["controlnet", "depth", "depth anything"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class DepthAnythingDepthEstimationInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates a depth map using a Depth Anything model."""
+
+ image: ImageField = InputField(description="The image to process")
+ model_size: DEPTH_ANYTHING_MODEL_SIZES = InputField(
+ default="small_v2", description="The size of the depth model to use"
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ model_url = DEPTH_ANYTHING_MODELS[self.model_size]
+ image = context.images.get_pil(self.image.image_name, "RGB")
+
+ loaded_model = context.models.load_remote_model(model_url, DepthAnythingPipeline.load_model)
+
+ with loaded_model as depth_anything_detector:
+ assert isinstance(depth_anything_detector, DepthAnythingPipeline)
+ depth_map = depth_anything_detector.generate_depth(image)
+
+ image_dto = context.images.save(image=depth_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/dw_openpose.py b/invokeai/app/invocations/dw_openpose.py
new file mode 100644
index 00000000000..918a4bc4d03
--- /dev/null
+++ b/invokeai/app/invocations/dw_openpose.py
@@ -0,0 +1,50 @@
+import onnxruntime as ort
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.dw_openpose import DWOpenposeDetector
+
+
+@invocation(
+ "dw_openpose_detection",
+ title="DW Openpose Detection",
+ tags=["controlnet", "dwpose", "openpose"],
+ category="controlnet_preprocessors",
+ version="1.1.1",
+)
+class DWOpenposeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an openpose pose from an image using DWPose"""
+
+ image: ImageField = InputField(description="The image to process")
+ draw_body: bool = InputField(default=True)
+ draw_face: bool = InputField(default=False)
+ draw_hands: bool = InputField(default=False)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+
+ onnx_det_path = context.models.download_and_cache_model(DWOpenposeDetector.get_model_url_det())
+ onnx_pose_path = context.models.download_and_cache_model(DWOpenposeDetector.get_model_url_pose())
+
+ loaded_session_det = context.models.load_local_model(
+ onnx_det_path, DWOpenposeDetector.create_onnx_inference_session
+ )
+ loaded_session_pose = context.models.load_local_model(
+ onnx_pose_path, DWOpenposeDetector.create_onnx_inference_session
+ )
+
+ with loaded_session_det as session_det, loaded_session_pose as session_pose:
+ assert isinstance(session_det, ort.InferenceSession)
+ assert isinstance(session_pose, ort.InferenceSession)
+ detector = DWOpenposeDetector(session_det=session_det, session_pose=session_pose)
+ detected_image = detector.run(
+ image,
+ draw_face=self.draw_face,
+ draw_hands=self.draw_hands,
+ draw_body=self.draw_body,
+ )
+ image_dto = context.images.save(image=detected_image)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/external_image_generation.py b/invokeai/app/invocations/external_image_generation.py
new file mode 100644
index 00000000000..a6c6822d9dd
--- /dev/null
+++ b/invokeai/app/invocations/external_image_generation.py
@@ -0,0 +1,351 @@
+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")
+ thinking_level: Literal["minimal", "high"] | None = InputField(
+ default=None, description="Thinking level for image generation"
+ )
+
+ def _build_provider_options(self) -> dict[str, Any] | None:
+ options: dict[str, Any] = {}
+ if self.temperature is not None:
+ options["temperature"] = self.temperature
+ if self.thinking_level is not None:
+ options["thinking_level"] = self.thinking_level
+ 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
+ if self.thinking_level is not None:
+ metadata["gemini_thinking_level"] = self.thinking_level
+ return metadata
+
+
+@invocation(
+ "seedream_image_generation",
+ title="Seedream Image Generation",
+ tags=["external", "generation", "seedream"],
+ category="image",
+ version="1.1.0",
+)
+class SeedreamImageGenerationInvocation(BaseExternalImageGenerationInvocation):
+ """Generate images using a BytePlus Seedream model."""
+
+ provider_id = "seedream"
+
+ 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=["seedream"],
+ )
+
+ # Seedream's API has only one endpoint and no inpaint support — mode is implicit
+ # from inputs (img2img happens automatically when init_image or reference_images
+ # are provided). Hide mode and mask_image since they have no effect.
+ mode: ExternalGenerationMode = InputField(default="txt2img", description="Generation mode.", ui_hidden=True)
+ mask_image: ImageField | None = InputField(default=None, description="Mask image for inpaint", ui_hidden=True)
+
+ watermark: bool = InputField(default=False, description="Add watermark to generated images")
+ optimize_prompt: bool = InputField(default=False, description="Let the model optimize the prompt before generation")
+
+ def _build_provider_options(self) -> dict[str, Any]:
+ return {
+ "watermark": self.watermark,
+ "optimize_prompt": self.optimize_prompt,
+ }
+
+ def _build_output_provider_metadata(self) -> dict[str, Any]:
+ return {
+ "seedream_watermark": self.watermark,
+ "seedream_optimize_prompt": self.optimize_prompt,
+ }
+
+
+@invocation(
+ "alibabacloud_image_generation",
+ title="Alibaba Cloud DashScope Image Generation",
+ tags=["external", "generation", "alibabacloud", "dashscope"],
+ category="image",
+ version="1.0.0",
+)
+class AlibabaCloudImageGenerationInvocation(BaseExternalImageGenerationInvocation):
+ """Generate images using an Alibaba Cloud DashScope external model."""
+
+ provider_id = "alibabacloud"
+
+ 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=["alibabacloud"],
+ )
diff --git a/invokeai/app/invocations/facetools.py b/invokeai/app/invocations/facetools.py
index 987f1b1e406..1092a67ce95 100644
--- a/invokeai/app/invocations/facetools.py
+++ b/invokeai/app/invocations/facetools.py
@@ -435,7 +435,9 @@ def get_faces_list(
return all_faces
-@invocation("face_off", title="FaceOff", tags=["image", "faceoff", "face", "mask"], category="image", version="1.2.2")
+@invocation(
+ "face_off", title="FaceOff", tags=["image", "faceoff", "face", "mask"], category="segmentation", version="1.2.2"
+)
class FaceOffInvocation(BaseInvocation, WithMetadata):
"""Bound, extract, and mask a face from an image using MediaPipe detection"""
@@ -514,7 +516,9 @@ def invoke(self, context: InvocationContext) -> FaceOffOutput:
return output
-@invocation("face_mask_detection", title="FaceMask", tags=["image", "face", "mask"], category="image", version="1.2.2")
+@invocation(
+ "face_mask_detection", title="FaceMask", tags=["image", "face", "mask"], category="segmentation", version="1.2.2"
+)
class FaceMaskInvocation(BaseInvocation, WithMetadata):
"""Face mask creation using mediapipe face detection"""
@@ -617,7 +621,11 @@ def invoke(self, context: InvocationContext) -> FaceMaskOutput:
@invocation(
- "face_identifier", title="FaceIdentifier", tags=["image", "face", "identifier"], category="image", version="1.2.2"
+ "face_identifier",
+ title="FaceIdentifier",
+ tags=["image", "face", "identifier"],
+ category="segmentation",
+ version="1.2.2",
)
class FaceIdentifierInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Outputs an image with detected face IDs printed on each face. For use with other FaceTools."""
diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py
index 0fa0216f1c7..e53aeb417b2 100644
--- a/invokeai/app/invocations/fields.py
+++ b/invokeai/app/invocations/fields.py
@@ -6,6 +6,14 @@
from pydantic_core import PydanticUndefined
from invokeai.app.util.metaenum import MetaEnum
+from invokeai.backend.image_util.segment_anything.shared import BoundingBox
+from invokeai.backend.model_manager.taxonomy import (
+ BaseModelType,
+ ClipVariantType,
+ ModelFormat,
+ ModelType,
+ ModelVariantType,
+)
from invokeai.backend.util.logging import InvokeAILogger
logger = InvokeAILogger.get_logger()
@@ -38,18 +46,6 @@ class UIType(str, Enum, metaclass=MetaEnum):
used, and the type will be ignored. They are included here for backwards compatibility.
"""
- # region Model Field Types
- MainModel = "MainModelField"
- SDXLMainModel = "SDXLMainModelField"
- SDXLRefinerModel = "SDXLRefinerModelField"
- ONNXModel = "ONNXModelField"
- VAEModel = "VAEModelField"
- LoRAModel = "LoRAModelField"
- ControlNetModel = "ControlNetModelField"
- IPAdapterModel = "IPAdapterModelField"
- T2IAdapterModel = "T2IAdapterModelField"
- # endregion
-
# region Misc Field Types
Scheduler = "SchedulerField"
Any = "AnyField"
@@ -58,6 +54,7 @@ class UIType(str, Enum, metaclass=MetaEnum):
# region Internal Field Types
_Collection = "CollectionField"
_CollectionItem = "CollectionItemField"
+ _IsIntermediate = "IsIntermediate"
# endregion
# region DEPRECATED
@@ -95,13 +92,44 @@ class UIType(str, Enum, metaclass=MetaEnum):
CollectionItem = "DEPRECATED_CollectionItem"
Enum = "DEPRECATED_Enum"
WorkflowField = "DEPRECATED_WorkflowField"
- IsIntermediate = "DEPRECATED_IsIntermediate"
BoardField = "DEPRECATED_BoardField"
MetadataItem = "DEPRECATED_MetadataItem"
MetadataItemCollection = "DEPRECATED_MetadataItemCollection"
MetadataItemPolymorphic = "DEPRECATED_MetadataItemPolymorphic"
MetadataDict = "DEPRECATED_MetadataDict"
+ # Deprecated Model Field Types - use ui_model_[base|type|variant|format] instead
+ MainModel = "DEPRECATED_MainModelField"
+ CogView4MainModel = "DEPRECATED_CogView4MainModelField"
+ FluxMainModel = "DEPRECATED_FluxMainModelField"
+ SD3MainModel = "DEPRECATED_SD3MainModelField"
+ SDXLMainModel = "DEPRECATED_SDXLMainModelField"
+ SDXLRefinerModel = "DEPRECATED_SDXLRefinerModelField"
+ ONNXModel = "DEPRECATED_ONNXModelField"
+ VAEModel = "DEPRECATED_VAEModelField"
+ FluxVAEModel = "DEPRECATED_FluxVAEModelField"
+ LoRAModel = "DEPRECATED_LoRAModelField"
+ ControlNetModel = "DEPRECATED_ControlNetModelField"
+ IPAdapterModel = "DEPRECATED_IPAdapterModelField"
+ T2IAdapterModel = "DEPRECATED_T2IAdapterModelField"
+ T5EncoderModel = "DEPRECATED_T5EncoderModelField"
+ CLIPEmbedModel = "DEPRECATED_CLIPEmbedModelField"
+ CLIPLEmbedModel = "DEPRECATED_CLIPLEmbedModelField"
+ CLIPGEmbedModel = "DEPRECATED_CLIPGEmbedModelField"
+ SpandrelImageToImageModel = "DEPRECATED_SpandrelImageToImageModelField"
+ ControlLoRAModel = "DEPRECATED_ControlLoRAModelField"
+ SigLipModel = "DEPRECATED_SigLipModelField"
+ FluxReduxModel = "DEPRECATED_FluxReduxModelField"
+ LlavaOnevisionModel = "DEPRECATED_LLaVAModelField"
+ Imagen3Model = "DEPRECATED_Imagen3ModelField"
+ Imagen4Model = "DEPRECATED_Imagen4ModelField"
+ ChatGPT4oModel = "DEPRECATED_ChatGPT4oModelField"
+ Gemini2_5Model = "DEPRECATED_Gemini2_5ModelField"
+ FluxKontextModel = "DEPRECATED_FluxKontextModelField"
+ Veo3Model = "DEPRECATED_Veo3ModelField"
+ RunwayModel = "DEPRECATED_RunwayModelField"
+ # endregion
+
class UIComponent(str, Enum, metaclass=MetaEnum):
"""
@@ -124,16 +152,32 @@ class FieldDescriptions:
negative_cond = "Negative conditioning tensor"
noise = "Noise tensor"
clip = "CLIP (tokenizer, text encoder, LoRAs) and skipped layer count"
+ t5_encoder = "T5 tokenizer and text encoder"
+ glm_encoder = "GLM (THUDM) tokenizer and text encoder"
+ qwen3_encoder = "Qwen3 tokenizer and text encoder"
+ clip_embed_model = "CLIP Embed loader"
+ clip_g_model = "CLIP-G Embed loader"
unet = "UNet (scheduler, LoRAs)"
+ transformer = "Transformer"
+ mmditx = "MMDiTX"
vae = "VAE"
cond = "Conditioning tensor"
controlnet_model = "ControlNet model to load"
vae_model = "VAE model to load"
lora_model = "LoRA model to load"
+ control_lora_model = "Control LoRA model to load"
main_model = "Main model (UNet, VAE, CLIP) to load"
+ flux_model = "Flux model (Transformer) to load"
+ sd3_model = "SD3 model (MMDiTX) to load"
+ cogview4_model = "CogView4 model (Transformer) to load"
+ z_image_model = "Z-Image model (Transformer) to load"
+ qwen_image_model = "Qwen Image Edit model (Transformer) to load"
+ qwen_vl_encoder = "Qwen2.5-VL tokenizer, processor and text/vision encoder"
sdxl_main_model = "SDXL Main model (UNet, VAE, CLIP1, CLIP2) to load"
sdxl_refiner_model = "SDXL Refiner Main Modde (UNet, VAE, CLIP2) to load"
onnx_main_model = "ONNX Main model (UNet, VAE, CLIP) to load"
+ spandrel_image_to_image_model = "Image-to-Image model"
+ vllm_model = "VLLM model"
lora_weight = "The weight at which the LoRA is applied to each model"
compel_prompt = "Prompt to be parsed by Compel to create a conditioning tensor"
raw_prompt = "Raw prompt text (no parsing)"
@@ -160,6 +204,7 @@ class FieldDescriptions:
fp32 = "Whether or not to use full float32 precision"
precision = "Precision to use"
tiled = "Processing using overlapping tiles (reduce memory consumption)"
+ vae_tile_size = "The tile size for VAE tiling in pixels (image space). If set to 0, the default tile size for the model will be used. Larger tile sizes generally produce better results at the cost of higher memory usage."
detect_res = "Pixel resolution for detection"
image_res = "Pixel resolution for output image"
safe_mode = "Whether or not to use safe mode"
@@ -170,7 +215,7 @@ class FieldDescriptions:
)
num_1 = "The first number"
num_2 = "The second number"
- mask = "The mask to use for the operation"
+ denoise_mask = "A mask of the region to apply the denoising process to. Values of 0.0 represent the regions to be fully denoised, and 1.0 represent the regions to be preserved."
board = "The board to save the image to"
image = "The image to process"
tile_size = "Tile size"
@@ -181,6 +226,12 @@ class FieldDescriptions:
freeu_s2 = 'Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the "oversmoothing effect" in the enhanced denoising process.'
freeu_b1 = "Scaling factor for stage 1 to amplify the contributions of backbone features."
freeu_b2 = "Scaling factor for stage 2 to amplify the contributions of backbone features."
+ instantx_control_mode = "The control mode for InstantX ControlNet union models. Ignored for other ControlNet models. The standard mapping is: canny (0), tile (1), depth (2), blur (3), pose (4), gray (5), low quality (6). Negative values will be treated as 'None'."
+ flux_redux_conditioning = "FLUX Redux conditioning tensor"
+ vllm_model = "The VLLM model to use"
+ text_llm_model = "The text language model to use for text generation"
+ flux_fill_conditioning = "FLUX Fill conditioning tensor"
+ flux_kontext_conditioning = "FLUX Kontext conditioning (reference image)"
class ImageField(BaseModel):
@@ -195,6 +246,12 @@ class BoardField(BaseModel):
board_id: str = Field(description="The id of the board")
+class StylePresetField(BaseModel):
+ """A style preset primitive field"""
+
+ style_preset_id: str = Field(description="The id of the style preset")
+
+
class DenoiseMaskField(BaseModel):
"""An inpaint mask field"""
@@ -228,6 +285,85 @@ def tuple(self) -> Tuple[int, int, int, int]:
return (self.r, self.g, self.b, self.a)
+class FluxConditioningField(BaseModel):
+ """A conditioning tensor primitive value"""
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+ mask: Optional[TensorField] = Field(
+ default=None,
+ description="The mask associated with this conditioning tensor. Excluded regions should be set to False, "
+ "included regions should be set to True.",
+ )
+
+
+class FluxReduxConditioningField(BaseModel):
+ """A FLUX Redux conditioning tensor primitive value"""
+
+ conditioning: TensorField = Field(description="The Redux image conditioning tensor.")
+ mask: Optional[TensorField] = Field(
+ default=None,
+ description="The mask associated with this conditioning tensor. Excluded regions should be set to False, "
+ "included regions should be set to True.",
+ )
+
+
+class FluxFillConditioningField(BaseModel):
+ """A FLUX Fill conditioning field."""
+
+ image: ImageField = Field(description="The FLUX Fill reference image.")
+ mask: TensorField = Field(description="The FLUX Fill inpaint mask.")
+
+
+class FluxKontextConditioningField(BaseModel):
+ """A conditioning field for FLUX Kontext (reference image)."""
+
+ image: ImageField = Field(description="The Kontext reference image.")
+
+
+class SD3ConditioningField(BaseModel):
+ """A conditioning tensor primitive value"""
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+
+
+class CogView4ConditioningField(BaseModel):
+ """A conditioning tensor primitive value"""
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+
+
+class ZImageConditioningField(BaseModel):
+ """A Z-Image conditioning tensor primitive value"""
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+ mask: Optional[TensorField] = Field(
+ default=None,
+ description="The mask associated with this conditioning tensor for regional prompting. "
+ "Excluded regions should be set to False, included regions should be set to True.",
+ )
+
+
+class QwenImageConditioningField(BaseModel):
+ """A Qwen Image Edit conditioning tensor primitive value"""
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+
+
+class AnimaConditioningField(BaseModel):
+ """An Anima conditioning tensor primitive value.
+
+ Anima conditioning contains Qwen3 0.6B hidden states and T5-XXL token IDs,
+ which are combined by the LLM Adapter inside the transformer.
+ """
+
+ conditioning_name: str = Field(description="The name of conditioning tensor")
+ mask: Optional[TensorField] = Field(
+ default=None,
+ description="The mask associated with this conditioning tensor for regional prompting. "
+ "Excluded regions should be set to False, included regions should be set to True.",
+ )
+
+
class ConditioningField(BaseModel):
"""A conditioning tensor primitive value"""
@@ -239,6 +375,18 @@ class ConditioningField(BaseModel):
)
+class BoundingBoxField(BoundingBox):
+ """A bounding box primitive value."""
+
+ score: Optional[float] = Field(
+ default=None,
+ ge=0.0,
+ le=1.0,
+ description="The score associated with the bounding box. In the range [0, 1]. This value is typically set "
+ "when the bounding box was produced by a detector and has an associated confidence score.",
+ )
+
+
class MetadataField(RootModel[dict[str, Any]]):
"""
Pydantic model for metadata with custom root of type dict[str, Any].
@@ -295,8 +443,8 @@ class InputFieldJSONSchemaExtra(BaseModel):
"""
input: Input
- orig_required: bool
field_kind: FieldKind
+ orig_required: bool = True
default: Optional[Any] = None
orig_default: Optional[Any] = None
ui_hidden: bool = False
@@ -304,10 +452,16 @@ class InputFieldJSONSchemaExtra(BaseModel):
ui_component: Optional[UIComponent] = None
ui_order: Optional[int] = None
ui_choice_labels: Optional[dict[str, str]] = None
+ ui_model_base: Optional[list[BaseModelType]] = None
+ 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,
json_schema_serialization_defaults_required=True,
+ use_enum_values=True,
)
@@ -331,7 +485,7 @@ class WithWorkflow:
workflow = None
def __init_subclass__(cls) -> None:
- logger.warn(
+ logger.warning(
f"{cls.__module__.split('.')[0]}.{cls.__name__}: WithWorkflow is deprecated. Use `context.workflow` to access the workflow."
)
super().__init_subclass__()
@@ -360,16 +514,100 @@ class OutputFieldJSONSchemaExtra(BaseModel):
"""
field_kind: FieldKind
- ui_hidden: bool
- ui_type: Optional[UIType]
- ui_order: Optional[int]
+ ui_hidden: bool = False
+ ui_order: Optional[int] = None
+ ui_type: Optional[UIType] = None
model_config = ConfigDict(
validate_assignment=True,
json_schema_serialization_defaults_required=True,
+ use_enum_values=True,
)
+def migrate_model_ui_type(ui_type: UIType | str, json_schema_extra: dict[str, Any]) -> bool:
+ """Migrate deprecated model-specifier ui_type values to new-style ui_model_[base|type|variant|format] in json_schema_extra."""
+ if not isinstance(ui_type, UIType):
+ ui_type = UIType(ui_type)
+
+ ui_model_type: list[ModelType] | None = None
+ ui_model_base: list[BaseModelType] | None = None
+ ui_model_format: list[ModelFormat] | None = None
+ ui_model_variant: list[ClipVariantType | ModelVariantType] | None = None
+
+ match ui_type:
+ case UIType.MainModel:
+ ui_model_base = [BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]
+ ui_model_type = [ModelType.Main]
+ case UIType.CogView4MainModel:
+ ui_model_base = [BaseModelType.CogView4]
+ ui_model_type = [ModelType.Main]
+ case UIType.FluxMainModel:
+ ui_model_base = [BaseModelType.Flux]
+ ui_model_type = [ModelType.Main]
+ case UIType.SD3MainModel:
+ ui_model_base = [BaseModelType.StableDiffusion3]
+ ui_model_type = [ModelType.Main]
+ case UIType.SDXLMainModel:
+ ui_model_base = [BaseModelType.StableDiffusionXL]
+ ui_model_type = [ModelType.Main]
+ case UIType.SDXLRefinerModel:
+ ui_model_base = [BaseModelType.StableDiffusionXLRefiner]
+ ui_model_type = [ModelType.Main]
+ case UIType.VAEModel:
+ ui_model_type = [ModelType.VAE]
+ case UIType.FluxVAEModel:
+ ui_model_base = [BaseModelType.Flux, BaseModelType.Flux2]
+ ui_model_type = [ModelType.VAE]
+ case UIType.LoRAModel:
+ ui_model_type = [ModelType.LoRA]
+ case UIType.ControlNetModel:
+ ui_model_type = [ModelType.ControlNet]
+ case UIType.IPAdapterModel:
+ ui_model_type = [ModelType.IPAdapter]
+ case UIType.T2IAdapterModel:
+ ui_model_type = [ModelType.T2IAdapter]
+ case UIType.T5EncoderModel:
+ ui_model_type = [ModelType.T5Encoder]
+ case UIType.CLIPEmbedModel:
+ ui_model_type = [ModelType.CLIPEmbed]
+ case UIType.CLIPLEmbedModel:
+ ui_model_type = [ModelType.CLIPEmbed]
+ ui_model_variant = [ClipVariantType.L]
+ case UIType.CLIPGEmbedModel:
+ ui_model_type = [ModelType.CLIPEmbed]
+ ui_model_variant = [ClipVariantType.G]
+ case UIType.SpandrelImageToImageModel:
+ ui_model_type = [ModelType.SpandrelImageToImage]
+ case UIType.ControlLoRAModel:
+ ui_model_type = [ModelType.ControlLoRa]
+ case UIType.SigLipModel:
+ ui_model_type = [ModelType.SigLIP]
+ case UIType.FluxReduxModel:
+ ui_model_type = [ModelType.FluxRedux]
+ case UIType.LlavaOnevisionModel:
+ ui_model_type = [ModelType.LlavaOnevision]
+ case _:
+ pass
+
+ did_migrate = False
+
+ if ui_model_type is not None:
+ json_schema_extra["ui_model_type"] = [m.value for m in ui_model_type]
+ did_migrate = True
+ if ui_model_base is not None:
+ json_schema_extra["ui_model_base"] = [m.value for m in ui_model_base]
+ did_migrate = True
+ if ui_model_format is not None:
+ json_schema_extra["ui_model_format"] = [m.value for m in ui_model_format]
+ did_migrate = True
+ if ui_model_variant is not None:
+ json_schema_extra["ui_model_variant"] = [m.value for m in ui_model_variant]
+ did_migrate = True
+
+ return did_migrate
+
+
def InputField(
# copied from pydantic's Field
# TODO: Can we support default_factory?
@@ -393,51 +631,115 @@ def InputField(
input: Input = Input.Any,
ui_type: Optional[UIType] = None,
ui_component: Optional[UIComponent] = None,
- ui_hidden: bool = False,
+ ui_hidden: Optional[bool] = None,
ui_order: Optional[int] = None,
ui_choice_labels: Optional[dict[str, str]] = None,
+ ui_model_base: Optional[BaseModelType | list[BaseModelType]] = None,
+ 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.
- This is a wrapper for Pydantic's [Field](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field) \
+ This is a wrapper for Pydantic's [Field](https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field)
that adds a few extra parameters to support graph execution and the node editor UI.
- :param Input input: [Input.Any] The kind of input this field requires. \
- `Input.Direct` means a value must be provided on instantiation. \
- `Input.Connection` means the value must be provided by a connection. \
- `Input.Any` means either will do.
+ If the field is a `ModelIdentifierField`, use the `ui_model_[base|type|variant|format]` args to filter the model list
+ in the Workflow Editor. Otherwise, use `ui_type` to provide extra type hints for the UI.
+
+ Don't use both `ui_type` and `ui_model_[base|type|variant|format]` - if both are provided, a warning will be
+ logged and `ui_type` will be ignored.
- :param UIType ui_type: [None] Optionally provides an extra type hint for the UI. \
- In some situations, the field's type is not enough to infer the correct UI type. \
- For example, model selection fields should render a dropdown UI component to select a model. \
- Internally, there is no difference between SD-1, SD-2 and SDXL model fields, they all use \
- `MainModelField`. So to ensure the base-model-specific UI is rendered, you can use \
- `UIType.SDXLMainModelField` to indicate that the field is an SDXL main model field.
+ Args:
+ input: The kind of input this field requires.
+ - `Input.Direct` means a value must be provided on instantiation.
+ - `Input.Connection` means the value must be provided by a connection.
+ - `Input.Any` means either will do.
- :param UIComponent ui_component: [None] Optionally specifies a specific component to use in the UI. \
- The UI will always render a suitable component, but sometimes you want something different than the default. \
- For example, a `string` field will default to a single-line input, but you may want a multi-line textarea instead. \
- For this case, you could provide `UIComponent.Textarea`.
+ ui_type: Optionally provides an extra type hint for the UI. In some situations, the field's type is not enough
+ to infer the correct UI type. For example, Scheduler fields are enums, but we want to render a special scheduler
+ dropdown in the UI. Use `UIType.Scheduler` to indicate this.
- :param bool ui_hidden: [False] Specifies whether or not this field should be hidden in the UI.
+ ui_component: Optionally specifies a specific component to use in the UI. The UI will always render a suitable
+ component, but sometimes you want something different than the default. For example, a `string` field will
+ default to a single-line input, but you may want a multi-line textarea instead. In this case, you could use
+ `UIComponent.Textarea`.
- :param int ui_order: [None] Specifies the order in which this field should be rendered in the UI.
+ ui_hidden: Specifies whether or not this field should be hidden in the UI.
- :param dict[str, str] ui_choice_labels: [None] Specifies the labels to use for the choices in an enum field.
+ ui_order: Specifies the order in which this field should be rendered in the UI. If omitted, the field will be
+ rendered after all fields with an explicit order, in the order they are defined in the Invocation class.
+
+ ui_model_base: Specifies the base model architectures to filter the model list by in the Workflow Editor. For
+ example, `ui_model_base=BaseModelType.StableDiffusionXL` will show only SDXL architecture models. This arg is
+ only valid if this Input field is annotated as a `ModelIdentifierField`.
+
+ ui_model_type: Specifies the model type(s) to filter the model list by in the Workflow Editor. For example,
+ `ui_model_type=ModelType.VAE` will show only VAE models. This arg is only valid if this Input field is
+ annotated as a `ModelIdentifierField`.
+
+ ui_model_variant: Specifies the model variant(s) to filter the model list by in the Workflow Editor. For example,
+ `ui_model_variant=ModelVariantType.Inpainting` will show only inpainting models. This arg is only valid if this
+ Input field is annotated as a `ModelIdentifierField`.
+
+ ui_model_format: Specifies the model format(s) to filter the model list by in the Workflow Editor. For example,
+ `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",
+ "choice3": "Choice 3"}` will render a dropdown with the labels "Choice 1", "Choice 2" and "Choice 3".
"""
json_schema_extra_ = InputFieldJSONSchemaExtra(
input=input,
- ui_type=ui_type,
- ui_component=ui_component,
- ui_hidden=ui_hidden,
- ui_order=ui_order,
- ui_choice_labels=ui_choice_labels,
field_kind=FieldKind.Input,
- orig_required=True,
)
+ if ui_component is not None:
+ json_schema_extra_.ui_component = ui_component
+ if ui_hidden is not None:
+ json_schema_extra_.ui_hidden = ui_hidden
+ if ui_order is not None:
+ json_schema_extra_.ui_order = ui_order
+ if ui_choice_labels is not None:
+ json_schema_extra_.ui_choice_labels = ui_choice_labels
+ if ui_model_base is not None:
+ if isinstance(ui_model_base, list):
+ json_schema_extra_.ui_model_base = ui_model_base
+ else:
+ json_schema_extra_.ui_model_base = [ui_model_base]
+ if ui_model_type is not None:
+ if isinstance(ui_model_type, list):
+ json_schema_extra_.ui_model_type = ui_model_type
+ else:
+ json_schema_extra_.ui_model_type = [ui_model_type]
+ if ui_model_variant is not None:
+ if isinstance(ui_model_variant, list):
+ json_schema_extra_.ui_model_variant = ui_model_variant
+ else:
+ json_schema_extra_.ui_model_variant = [ui_model_variant]
+ if ui_model_format is not None:
+ if isinstance(ui_model_format, list):
+ 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
+
"""
There is a conflict between the typing of invocation definitions and the typing of an invocation's
`invoke()` function.
@@ -467,7 +769,7 @@ def InputField(
if default_factory is not _Unset and default_factory is not None:
default = default_factory()
- logger.warn('"default_factory" is not supported, calling it now to set "default"')
+ logger.warning('"default_factory" is not supported, calling it now to set "default"')
# These are the args we may wish pass to the pydantic `Field()` function
field_args = {
@@ -509,7 +811,7 @@ def InputField(
return Field(
**provided_args,
- json_schema_extra=json_schema_extra_.model_dump(exclude_none=True),
+ json_schema_extra=json_schema_extra_.model_dump(exclude_unset=True),
)
@@ -538,20 +840,20 @@ def OutputField(
"""
Creates an output field for an invocation output.
- This is a wrapper for Pydantic's [Field](https://docs.pydantic.dev/1.10/usage/schema/#field-customization) \
+ This is a wrapper for Pydantic's [Field](https://docs.pydantic.dev/1.10/usage/schema/#field-customization)
that adds a few extra parameters to support graph execution and the node editor UI.
- :param UIType ui_type: [None] Optionally provides an extra type hint for the UI. \
- In some situations, the field's type is not enough to infer the correct UI type. \
- For example, model selection fields should render a dropdown UI component to select a model. \
- Internally, there is no difference between SD-1, SD-2 and SDXL model fields, they all use \
- `MainModelField`. So to ensure the base-model-specific UI is rendered, you can use \
- `UIType.SDXLMainModelField` to indicate that the field is an SDXL main model field.
+ Args:
+ ui_type: Optionally provides an extra type hint for the UI. In some situations, the field's type is not enough
+ to infer the correct UI type. For example, Scheduler fields are enums, but we want to render a special scheduler
+ dropdown in the UI. Use `UIType.Scheduler` to indicate this.
- :param bool ui_hidden: [False] Specifies whether or not this field should be hidden in the UI. \
+ ui_hidden: Specifies whether or not this field should be hidden in the UI.
- :param int ui_order: [None] Specifies the order in which this field should be rendered in the UI. \
+ ui_order: Specifies the order in which this field should be rendered in the UI. If omitted, the field will be
+ rendered after all fields with an explicit order, in the order they are defined in the Invocation class.
"""
+
return Field(
default=default,
title=title,
@@ -569,9 +871,9 @@ def OutputField(
min_length=min_length,
max_length=max_length,
json_schema_extra=OutputFieldJSONSchemaExtra(
- ui_type=ui_type,
ui_hidden=ui_hidden,
ui_order=ui_order,
+ ui_type=ui_type,
field_kind=FieldKind.Output,
).model_dump(exclude_none=True),
)
diff --git a/invokeai/app/invocations/flux2_denoise.py b/invokeai/app/invocations/flux2_denoise.py
new file mode 100644
index 00000000000..3b9d3d4ce89
--- /dev/null
+++ b/invokeai/app/invocations/flux2_denoise.py
@@ -0,0 +1,579 @@
+"""Flux2 Klein Denoise Invocation.
+
+Run denoising process with a FLUX.2 Klein transformer model.
+Uses Qwen3 conditioning instead of CLIP+T5.
+"""
+
+from contextlib import ExitStack
+from typing import Callable, Iterator, Optional, Tuple
+
+import torch
+import torchvision.transforms as tv_transforms
+from torchvision.transforms.functional import resize as tv_resize
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ FluxConditioningField,
+ FluxKontextConditioningField,
+ Input,
+ InputField,
+ LatentsField,
+)
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import TransformerField, VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional
+from invokeai.backend.flux.schedulers import FLUX_SCHEDULER_LABELS, FLUX_SCHEDULER_MAP, FLUX_SCHEDULER_NAME_VALUES
+from invokeai.backend.flux2.denoise import denoise
+from invokeai.backend.flux2.ref_image_extension import Flux2RefImageExtension
+from invokeai.backend.flux2.sampling_utils import (
+ compute_empirical_mu,
+ generate_img_ids_flux2,
+ get_noise_flux2,
+ get_schedule_flux2,
+ pack_flux2,
+ unpack_flux2,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.flux_bfl_peft_lora_conversion_utils import (
+ convert_bfl_lora_patch_to_diffusers,
+)
+from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "flux2_denoise",
+ title="FLUX2 Denoise",
+ tags=["image", "flux", "flux2", "klein", "denoise"],
+ category="latents",
+ version="1.5.0",
+ classification=Classification.Prototype,
+)
+class Flux2DenoiseInvocation(BaseInvocation):
+ """Run denoising process with a FLUX.2 Klein transformer model.
+
+ This node is designed for FLUX.2 Klein models which use Qwen3 as the text encoder.
+ It does not support ControlNet, IP-Adapters, or regional prompting.
+ """
+
+ latents: Optional[LatentsField] = InputField(
+ default=None,
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None,
+ description=FieldDescriptions.noise,
+ input=Input.Connection,
+ )
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None,
+ description=FieldDescriptions.denoise_mask,
+ input=Input.Connection,
+ )
+ denoising_start: float = InputField(
+ default=0.0,
+ ge=0,
+ le=1,
+ description=FieldDescriptions.denoising_start,
+ )
+ denoising_end: float = InputField(
+ default=1.0,
+ ge=0,
+ le=1,
+ description=FieldDescriptions.denoising_end,
+ )
+ add_noise: bool = InputField(default=True, description="Add noise based on denoising start.")
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.flux_model,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ positive_text_conditioning: FluxConditioningField = InputField(
+ description=FieldDescriptions.positive_cond,
+ input=Input.Connection,
+ )
+ negative_text_conditioning: Optional[FluxConditioningField] = InputField(
+ default=None,
+ description="Negative conditioning tensor. Can be None if cfg_scale is 1.0.",
+ input=Input.Connection,
+ )
+ guidance: float = InputField(
+ default=4.0,
+ ge=0,
+ le=20,
+ description="Guidance strength for distilled guidance-embedding models. "
+ "Inert for all current FLUX.2 Klein variants (their guidance_embeds weights are absent/zero); "
+ "kept for node-graph compatibility and future guidance-embedded models.",
+ )
+ cfg_scale: float = InputField(
+ default=1.0,
+ description=FieldDescriptions.cfg_scale,
+ title="CFG Scale",
+ )
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ num_steps: int = InputField(
+ default=4,
+ description="Number of diffusion steps. Use 4 for distilled models, 28+ for base models.",
+ )
+ scheduler: FLUX_SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description="Scheduler (sampler) for the denoising process. 'euler' is fast and standard. "
+ "'heun' is 2nd-order (better quality, 2x slower). 'lcm' is optimized for few steps.",
+ ui_choice_labels=FLUX_SCHEDULER_LABELS,
+ )
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+ vae: VAEField = InputField(
+ description="FLUX.2 VAE model (required for BN statistics).",
+ input=Input.Connection,
+ )
+ kontext_conditioning: FluxKontextConditioningField | list[FluxKontextConditioningField] | None = InputField(
+ default=None,
+ description="FLUX Kontext conditioning (reference images for multi-reference image editing).",
+ input=Input.Connection,
+ title="Reference Images",
+ )
+
+ def _get_bn_stats(self, context: InvocationContext) -> Optional[Tuple[torch.Tensor, torch.Tensor]]:
+ """Extract BN statistics from the FLUX.2 VAE.
+
+ The FLUX.2 VAE uses batch normalization on the patchified 128-channel representation.
+ IMPORTANT: BFL FLUX.2 VAE uses affine=False, so there are NO learnable weight/bias.
+
+ BN formula (affine=False): y = (x - mean) / std
+ Inverse: x = y * std + mean
+
+ Returns:
+ Tuple of (bn_mean, bn_std) tensors of shape (128,), or None if BN layer not found.
+ """
+ with context.models.load(self.vae.vae).model_on_device() as (_, vae):
+ # Ensure VAE is in eval mode to prevent BN stats from being updated
+ vae.eval()
+
+ # Try to find the BN layer - it may be at different locations depending on model format
+ bn_layer = None
+ if hasattr(vae, "bn"):
+ bn_layer = vae.bn
+ elif hasattr(vae, "batch_norm"):
+ bn_layer = vae.batch_norm
+ elif hasattr(vae, "encoder") and hasattr(vae.encoder, "bn"):
+ bn_layer = vae.encoder.bn
+
+ if bn_layer is None:
+ return None
+
+ # Verify running statistics are initialized
+ if bn_layer.running_mean is None or bn_layer.running_var is None:
+ return None
+
+ # Get BN running statistics from VAE
+ bn_mean = bn_layer.running_mean.clone() # Shape: (128,)
+ bn_var = bn_layer.running_var.clone() # Shape: (128,)
+ bn_eps = bn_layer.eps if hasattr(bn_layer, "eps") else 1e-4 # BFL uses 1e-4
+ bn_std = torch.sqrt(bn_var + bn_eps)
+
+ return bn_mean, bn_std
+
+ def _bn_normalize(
+ self,
+ x: torch.Tensor,
+ bn_mean: torch.Tensor,
+ bn_std: torch.Tensor,
+ ) -> torch.Tensor:
+ """Apply BN normalization to packed latents.
+
+ BN formula (affine=False): y = (x - mean) / std
+
+ Args:
+ x: Packed latents of shape (B, seq, 128).
+ bn_mean: BN running mean of shape (128,).
+ bn_std: BN running std of shape (128,).
+
+ Returns:
+ Normalized latents of same shape.
+ """
+ # x: (B, seq, 128), params: (128,) -> broadcast over batch and sequence dims
+ bn_mean = bn_mean.to(x.device, x.dtype)
+ bn_std = bn_std.to(x.device, x.dtype)
+ return (x - bn_mean) / bn_std
+
+ def _bn_denormalize(
+ self,
+ x: torch.Tensor,
+ bn_mean: torch.Tensor,
+ bn_std: torch.Tensor,
+ ) -> torch.Tensor:
+ """Apply BN denormalization to packed latents (inverse of normalization).
+
+ Inverse BN (affine=False): x = y * std + mean
+
+ Args:
+ x: Packed latents of shape (B, seq, 128).
+ bn_mean: BN running mean of shape (128,).
+ bn_std: BN running std of shape (128,).
+
+ Returns:
+ Denormalized latents of same shape.
+ """
+ # x: (B, seq, 128), params: (128,) -> broadcast over batch and sequence dims
+ bn_mean = bn_mean.to(x.device, x.dtype)
+ bn_std = bn_std.to(x.device, x.dtype)
+ return x * bn_std + bn_mean
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _run_diffusion(self, context: InvocationContext) -> torch.Tensor:
+ inference_dtype = torch.bfloat16
+ device = TorchDevice.choose_torch_device()
+
+ # Get BN statistics from VAE for latent denormalization (optional)
+ # BFL FLUX.2 VAE uses affine=False, so only mean/std are needed
+ # Some VAE formats (e.g. diffusers) may not expose BN stats directly
+ bn_stats = self._get_bn_stats(context)
+ bn_mean, bn_std = bn_stats if bn_stats is not None else (None, None)
+
+ # Load the input latents, if provided
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+
+ # Prepare input noise (FLUX.2 uses 32 channels).
+ # If noise will never be consumed, avoid validating/loading it.
+ should_ignore_noise = init_latents is not None and not self.add_noise and self.denoise_mask is None
+ noise: Optional[torch.Tensor]
+ if should_ignore_noise:
+ noise = None
+ b, _c, latent_h, latent_w = init_latents.shape
+ else:
+ noise = self._prepare_noise_tensor(context, inference_dtype, device)
+ b, _c, latent_h, latent_w = noise.shape
+ packed_h = latent_h // 2
+ packed_w = latent_w // 2
+
+ # Load the conditioning data
+ pos_cond_data = context.conditioning.load(self.positive_text_conditioning.conditioning_name)
+ assert len(pos_cond_data.conditionings) == 1
+ pos_flux_conditioning = pos_cond_data.conditionings[0]
+ assert isinstance(pos_flux_conditioning, FLUXConditioningInfo)
+ pos_flux_conditioning = pos_flux_conditioning.to(dtype=inference_dtype, device=device)
+
+ # Qwen3 stacked embeddings (stored in t5_embeds field for compatibility)
+ txt = pos_flux_conditioning.t5_embeds
+
+ # Generate text position IDs (4D format for FLUX.2: T, H, W, L)
+ # FLUX.2 uses 4D position coordinates for its rotary position embeddings
+ # IMPORTANT: Position IDs must be int64 (long) dtype
+ # Diffusers uses: T=0, H=0, W=0, L=0..seq_len-1
+ seq_len = txt.shape[1]
+ txt_ids = torch.zeros(1, seq_len, 4, device=device, dtype=torch.long)
+ txt_ids[..., 3] = torch.arange(seq_len, device=device, dtype=torch.long) # L coordinate varies
+
+ # Load negative conditioning if provided
+ neg_txt = None
+ neg_txt_ids = None
+ if self.negative_text_conditioning is not None:
+ neg_cond_data = context.conditioning.load(self.negative_text_conditioning.conditioning_name)
+ assert len(neg_cond_data.conditionings) == 1
+ neg_flux_conditioning = neg_cond_data.conditionings[0]
+ assert isinstance(neg_flux_conditioning, FLUXConditioningInfo)
+ neg_flux_conditioning = neg_flux_conditioning.to(dtype=inference_dtype, device=device)
+ neg_txt = neg_flux_conditioning.t5_embeds
+ # For text tokens: T=0, H=0, W=0, L=0..seq_len-1 (only L varies per token)
+ neg_seq_len = neg_txt.shape[1]
+ neg_txt_ids = torch.zeros(1, neg_seq_len, 4, device=device, dtype=torch.long)
+ neg_txt_ids[..., 3] = torch.arange(neg_seq_len, device=device, dtype=torch.long)
+
+ # Validate transformer config
+ transformer_config = context.models.get_config(self.transformer.transformer)
+ assert transformer_config.base == BaseModelType.Flux2 and transformer_config.type == ModelType.Main
+
+ # Calculate the timestep schedule using FLUX.2 specific schedule
+ # This matches diffusers' Flux2Pipeline implementation
+ # Note: Schedule shifting is handled by the scheduler via mu parameter
+ image_seq_len = packed_h * packed_w
+ timesteps = get_schedule_flux2(
+ num_steps=self.num_steps,
+ image_seq_len=image_seq_len,
+ )
+ # Compute mu for dynamic schedule shifting (used by FlowMatchEulerDiscreteScheduler)
+ mu = compute_empirical_mu(image_seq_len=image_seq_len, num_steps=self.num_steps)
+
+ # Clip the timesteps schedule based on denoising_start and denoising_end
+ timesteps = clip_timestep_schedule_fractional(timesteps, self.denoising_start, self.denoising_end)
+
+ # Prepare input latent image
+ if init_latents is not None:
+ if self.add_noise:
+ assert noise is not None
+ # Noise the init latents using the first timestep from the clipped
+ # InvokeAI schedule.
+ #
+ # Known limitation: if a scheduler later uses a different first
+ # effective timestep/sigma than this precomputed schedule, the
+ # img2img preblend below may not match that scheduler exactly.
+ # This is an existing pipeline limitation and applies to both
+ # seed-generated noise and externally supplied noise.
+ t_0 = timesteps[0]
+ x = t_0 * noise + (1.0 - t_0) * init_latents
+ else:
+ x = init_latents
+ else:
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ assert noise is not None
+ x = noise
+
+ # If len(timesteps) == 1, then short-circuit
+ if len(timesteps) <= 1:
+ return x
+
+ # Generate image position IDs (FLUX.2 uses 4D coordinates)
+ # Position IDs use int64 dtype like diffusers
+ img_ids = generate_img_ids_flux2(h=latent_h, w=latent_w, batch_size=b, device=device)
+
+ # Prepare inpaint mask
+ inpaint_mask = self._prep_inpaint_mask(context, x)
+
+ # Pack all latent tensors
+ init_latents_packed = pack_flux2(init_latents) if init_latents is not None else None
+ inpaint_mask_packed = pack_flux2(inpaint_mask) if inpaint_mask is not None else None
+ noise_packed = pack_flux2(noise) if noise is not None else None
+ x = pack_flux2(x)
+
+ # BN normalization for img2img/inpainting:
+ # - The init_latents from VAE encode are NOT BN-normalized
+ # - The transformer operates in BN-normalized space
+ # - We must normalize x, init_latents, AND noise for InpaintExtension
+ # - Output MUST be denormalized after denoising before VAE decode
+ #
+ # This ensures that:
+ # 1. x starts in the correct normalized space for the transformer
+ # 2. When InpaintExtension merges intermediate_latents with noised_init_latents,
+ # both are in the same scale/space (noise and init_latents must be in same space
+ # for the linear interpolation: noised = noise * t + init * (1-t))
+ if bn_mean is not None and bn_std is not None:
+ if init_latents_packed is not None:
+ init_latents_packed = self._bn_normalize(init_latents_packed, bn_mean, bn_std)
+ # Also normalize noise for InpaintExtension - it's used to compute
+ # noised_init_latents = noise * t + init_latents * (1-t)
+ # Both operands must be in the same normalized space
+ if noise_packed is not None:
+ noise_packed = self._bn_normalize(noise_packed, bn_mean, bn_std)
+ # For img2img/inpainting, x is computed from init_latents and must also be normalized
+ # For txt2img, x is pure noise (already N(0,1)) - normalizing it would be incorrect
+ # We detect img2img by checking if init_latents was provided
+ if init_latents is not None:
+ x = self._bn_normalize(x, bn_mean, bn_std)
+
+ # Verify packed dimensions
+ assert packed_h * packed_w == x.shape[1]
+
+ # Prepare inpaint extension
+ inpaint_extension: Optional[RectifiedFlowInpaintExtension] = None
+ if inpaint_mask_packed is not None:
+ assert init_latents_packed is not None
+ assert noise_packed is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents_packed,
+ inpaint_mask=inpaint_mask_packed,
+ noise=noise_packed,
+ )
+
+ # Prepare CFG scale list
+ num_steps = len(timesteps) - 1
+ cfg_scale_list = [self.cfg_scale] * num_steps
+
+ # Check if we're doing inpainting (have a mask or a clipped schedule)
+ is_inpainting = self.denoise_mask is not None or self.denoising_start > 1e-5
+
+ # Create scheduler with FLUX.2 Klein configuration
+ # For inpainting/img2img, use manual Euler stepping to preserve the exact
+ # clipped timestep schedule used for the initial latent/noise preblend.
+ # For txt2img, use the scheduler with dynamic shifting for optimal results.
+ #
+ # This split is intentional. Reusing a scheduler for img2img here can
+ # change the first effective timestep/sigma and break parity with the
+ # preblend computed above.
+ scheduler = None
+ if self.scheduler in FLUX_SCHEDULER_MAP and not is_inpainting:
+ # Only use scheduler for txt2img - use manual Euler for inpainting to preserve exact timesteps
+ scheduler_class = FLUX_SCHEDULER_MAP[self.scheduler]
+ # FlowMatchHeunDiscreteScheduler only supports num_train_timesteps and shift parameters
+ # FlowMatchEulerDiscreteScheduler and FlowMatchLCMScheduler support dynamic shifting
+ if self.scheduler == "heun":
+ scheduler = scheduler_class(
+ num_train_timesteps=1000,
+ shift=3.0,
+ )
+ else:
+ scheduler = scheduler_class(
+ num_train_timesteps=1000,
+ shift=3.0,
+ use_dynamic_shifting=True,
+ base_shift=0.5,
+ max_shift=1.15,
+ base_image_seq_len=256,
+ max_image_seq_len=4096,
+ time_shift_type="exponential",
+ )
+
+ # Prepare reference image extension for FLUX.2 Klein built-in editing
+ ref_image_extension = None
+ if self.kontext_conditioning:
+ ref_image_extension = Flux2RefImageExtension(
+ context=context,
+ ref_image_conditioning=self.kontext_conditioning
+ if isinstance(self.kontext_conditioning, list)
+ else [self.kontext_conditioning],
+ vae_field=self.vae,
+ device=device,
+ dtype=inference_dtype,
+ bn_mean=bn_mean,
+ bn_std=bn_std,
+ )
+
+ with ExitStack() as exit_stack:
+ # Load the transformer model
+ (cached_weights, transformer) = exit_stack.enter_context(
+ context.models.load(self.transformer.transformer).model_on_device()
+ )
+ config = transformer_config
+
+ # Determine if the model is quantized
+ if config.format in [ModelFormat.Diffusers]:
+ model_is_quantized = False
+ elif config.format in [
+ ModelFormat.BnbQuantizedLlmInt8b,
+ ModelFormat.BnbQuantizednf4b,
+ ModelFormat.GGUFQuantized,
+ ]:
+ model_is_quantized = True
+ else:
+ model_is_quantized = False
+
+ # Apply LoRA models to the transformer
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=transformer,
+ patches=self._lora_iterator(context),
+ prefix=FLUX_LORA_TRANSFORMER_PREFIX,
+ dtype=inference_dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=model_is_quantized,
+ )
+ )
+
+ # Prepare reference image conditioning if provided
+ img_cond_seq = None
+ img_cond_seq_ids = None
+ if ref_image_extension is not None:
+ # Ensure batch sizes match
+ ref_image_extension.ensure_batch_size(x.shape[0])
+ img_cond_seq, img_cond_seq_ids = (
+ ref_image_extension.ref_image_latents,
+ ref_image_extension.ref_image_ids,
+ )
+
+ x = denoise(
+ model=transformer,
+ img=x,
+ img_ids=img_ids,
+ txt=txt,
+ txt_ids=txt_ids,
+ timesteps=timesteps,
+ step_callback=self._build_step_callback(context),
+ guidance=self.guidance,
+ cfg_scale=cfg_scale_list,
+ neg_txt=neg_txt,
+ neg_txt_ids=neg_txt_ids,
+ scheduler=scheduler,
+ mu=mu,
+ inpaint_extension=inpaint_extension,
+ img_cond_seq=img_cond_seq,
+ img_cond_seq_ids=img_cond_seq_ids,
+ )
+
+ # Apply BN denormalization if BN stats are available
+ # The diffusers Flux2KleinPipeline applies: latents = latents * bn_std + bn_mean
+ # This transforms latents from normalized space to VAE's expected input space
+ if bn_mean is not None and bn_std is not None:
+ x = self._bn_denormalize(x, bn_mean, bn_std)
+
+ x = unpack_flux2(x.float(), self.height, self.width)
+ return x
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "FLUX.2", self.width, self.height)
+ return noise
+
+ return get_noise_flux2(
+ num_samples=1,
+ height=self.height,
+ width=self.width,
+ device=device,
+ dtype=inference_dtype,
+ seed=self.seed,
+ )
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> Optional[torch.Tensor]:
+ """Prepare the inpaint mask."""
+ if self.denoise_mask is None:
+ return None
+
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask.expand_as(latents)
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply.
+
+ Converts BFL-format LoRA keys to diffusers format if needed, since FLUX.2 Klein
+ uses Flux2Transformer2DModel (diffusers naming) but LoRAs may have been loaded
+ with BFL naming (e.g. when a Klein 4B LoRA is misidentified as FLUX.1).
+ """
+ for lora in self.transformer.loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ converted = convert_bfl_lora_patch_to_diffusers(lora_info.model)
+ yield (converted, lora.weight)
+ del lora_info
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ """Build a callback for step progress updates."""
+
+ def step_callback(state: PipelineIntermediateState) -> None:
+ latents = state.latents.float()
+ state.latents = unpack_flux2(latents, self.height, self.width).squeeze()
+ context.util.flux2_step_callback(state)
+
+ return step_callback
diff --git a/invokeai/app/invocations/flux2_klein_lora_loader.py b/invokeai/app/invocations/flux2_klein_lora_loader.py
new file mode 100644
index 00000000000..b7d55b6b134
--- /dev/null
+++ b/invokeai/app/invocations/flux2_klein_lora_loader.py
@@ -0,0 +1,182 @@
+"""FLUX.2 Klein LoRA Loader Invocation.
+
+Applies LoRA models to a FLUX.2 Klein transformer and/or Qwen3 text encoder.
+Unlike standard FLUX which uses CLIP+T5, Klein uses only Qwen3 for text encoding.
+"""
+
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, Qwen3EncoderField, TransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("flux2_klein_lora_loader_output")
+class Flux2KleinLoRALoaderOutput(BaseInvocationOutput):
+ """FLUX.2 Klein LoRA Loader Output"""
+
+ transformer: Optional[TransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="Transformer"
+ )
+ qwen3_encoder: Optional[Qwen3EncoderField] = OutputField(
+ default=None, description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder"
+ )
+
+
+@invocation(
+ "flux2_klein_lora_loader",
+ title="Apply LoRA - Flux2 Klein",
+ tags=["lora", "model", "flux", "klein", "flux2"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class Flux2KleinLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA model to a FLUX.2 Klein transformer and/or Qwen3 text encoder."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.Flux2,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
+ transformer: TransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> Flux2KleinLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ # Warn if LoRA variant doesn't match transformer variant
+ lora_config = context.models.get_config(lora_key)
+ lora_variant = getattr(lora_config, "variant", None)
+ if lora_variant and self.transformer is not None:
+ transformer_config = context.models.get_config(self.transformer.transformer.key)
+ transformer_variant = getattr(transformer_config, "variant", None)
+ if transformer_variant and lora_variant != transformer_variant:
+ context.logger.warning(
+ f"LoRA variant mismatch: LoRA '{lora_config.name}' is for {lora_variant.value} "
+ f"but transformer is {transformer_variant.value}. This may cause shape errors."
+ )
+
+ # Check for existing LoRAs with the same key.
+ if self.transformer and any(lora.lora.key == lora_key for lora in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to transformer.')
+ if self.qwen3_encoder and any(lora.lora.key == lora_key for lora in self.qwen3_encoder.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to Qwen3 encoder.')
+
+ output = Flux2KleinLoRALoaderOutput()
+
+ # Attach LoRA layers to the models.
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ output.transformer.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+ output.qwen3_encoder.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "flux2_klein_lora_collection_loader",
+ title="Apply LoRA Collection - Flux2 Klein",
+ tags=["lora", "model", "flux", "klein", "flux2"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class Flux2KleinLoRACollectionLoader(BaseInvocation):
+ """Applies a collection of LoRAs to a FLUX.2 Klein transformer and/or Qwen3 text encoder."""
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+
+ transformer: Optional[TransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> Flux2KleinLoRALoaderOutput:
+ output = Flux2KleinLoRALoaderOutput()
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added_loras: list[str] = []
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+
+ for lora in loras:
+ if lora is None:
+ continue
+ if lora.lora.key in added_loras:
+ continue
+
+ if not context.models.exists(lora.lora.key):
+ raise Exception(f"Unknown lora: {lora.lora.key}!")
+
+ assert lora.lora.base in (BaseModelType.Flux, BaseModelType.Flux2)
+
+ # Warn if LoRA variant doesn't match transformer variant
+ lora_config = context.models.get_config(lora.lora.key)
+ lora_variant = getattr(lora_config, "variant", None)
+ if lora_variant and self.transformer is not None:
+ transformer_config = context.models.get_config(self.transformer.transformer.key)
+ transformer_variant = getattr(transformer_config, "variant", None)
+ if transformer_variant and lora_variant != transformer_variant:
+ context.logger.warning(
+ f"LoRA variant mismatch: LoRA '{lora_config.name}' is for {lora_variant.value} "
+ f"but transformer is {transformer_variant.value}. This may cause shape errors."
+ )
+
+ added_loras.append(lora.lora.key)
+
+ if self.transformer is not None and output.transformer is not None:
+ output.transformer.loras.append(lora)
+
+ if self.qwen3_encoder is not None and output.qwen3_encoder is not None:
+ output.qwen3_encoder.loras.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/flux2_klein_model_loader.py b/invokeai/app/invocations/flux2_klein_model_loader.py
new file mode 100644
index 00000000000..2091fd380d7
--- /dev/null
+++ b/invokeai/app/invocations/flux2_klein_model_loader.py
@@ -0,0 +1,222 @@
+"""Flux2 Klein Model Loader Invocation.
+
+Loads a Flux2 Klein model with its Qwen3 text encoder and VAE.
+Unlike standard FLUX which uses CLIP+T5, Klein uses only Qwen3.
+"""
+
+from typing import Literal, Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ ModelIdentifierField,
+ Qwen3EncoderField,
+ TransformerField,
+ VAEField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import (
+ BaseModelType,
+ Flux2VariantType,
+ ModelFormat,
+ ModelType,
+ Qwen3VariantType,
+ SubModelType,
+)
+
+
+@invocation_output("flux2_klein_model_loader_output")
+class Flux2KleinModelLoaderOutput(BaseInvocationOutput):
+ """Flux2 Klein model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ qwen3_encoder: Qwen3EncoderField = OutputField(description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+ max_seq_len: Literal[256, 512] = OutputField(
+ description="The max sequence length for the Qwen3 encoder.",
+ title="Max Seq Length",
+ )
+
+
+@invocation(
+ "flux2_klein_model_loader",
+ title="Main Model - Flux2 Klein",
+ tags=["model", "flux", "klein", "qwen3"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class Flux2KleinModelLoaderInvocation(BaseInvocation):
+ """Loads a Flux2 Klein model, outputting its submodels.
+
+ Flux2 Klein uses Qwen3 as the text encoder instead of CLIP+T5.
+ It uses a 32-channel VAE (AutoencoderKLFlux2) instead of the 16-channel FLUX.1 VAE.
+
+ When using a Diffusers format model, both VAE and Qwen3 encoder are extracted
+ automatically from the main model. You can override with standalone models:
+ - Transformer: Always from Flux2 Klein main model
+ - VAE: From main model (Diffusers) or standalone VAE
+ - Qwen3 Encoder: From main model (Diffusers) or standalone Qwen3 model
+ """
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.flux_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Flux2,
+ ui_model_type=ModelType.Main,
+ title="Transformer",
+ )
+
+ vae_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone VAE model. Flux2 Klein uses the same VAE as FLUX (16-channel). "
+ "If not provided, VAE will be loaded from the Qwen3 Source model.",
+ input=Input.Direct,
+ ui_model_base=[BaseModelType.Flux, BaseModelType.Flux2],
+ ui_model_type=ModelType.VAE,
+ title="VAE",
+ )
+
+ qwen3_encoder_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Qwen3 Encoder model. "
+ "If not provided, encoder will be loaded from the Qwen3 Source model.",
+ input=Input.Direct,
+ ui_model_type=ModelType.Qwen3Encoder,
+ title="Qwen3 Encoder",
+ )
+
+ qwen3_source_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Diffusers Flux2 Klein model to extract VAE and/or Qwen3 encoder from. "
+ "Use this if you don't have separate VAE/Qwen3 models. "
+ "Ignored if both VAE and Qwen3 Encoder are provided separately.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Flux2,
+ ui_model_type=ModelType.Main,
+ ui_model_format=ModelFormat.Diffusers,
+ title="Qwen3 Source (Diffusers)",
+ )
+
+ max_seq_len: Literal[256, 512] = InputField(
+ default=512,
+ description="Max sequence length for the Qwen3 encoder.",
+ title="Max Seq Length",
+ )
+
+ def invoke(self, context: InvocationContext) -> Flux2KleinModelLoaderOutput:
+ # Transformer always comes from the main model
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+
+ # Check if main model is Diffusers format (can extract VAE directly)
+ main_config = context.models.get_config(self.model)
+ main_is_diffusers = main_config.format == ModelFormat.Diffusers
+
+ # Determine VAE source
+ # IMPORTANT: FLUX.2 Klein uses a 32-channel VAE (AutoencoderKLFlux2), not the 16-channel FLUX.1 VAE.
+ # The VAE should come from the FLUX.2 Klein Diffusers model, not a separate FLUX VAE.
+ if self.vae_model is not None:
+ # Use standalone VAE (user explicitly selected one)
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif main_is_diffusers:
+ # Extract VAE from main model (recommended for FLUX.2)
+ vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif self.qwen3_source_model is not None:
+ # Extract from Qwen3 source Diffusers model
+ self._validate_diffusers_format(context, self.qwen3_source_model, "Qwen3 Source")
+ vae = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ else:
+ raise ValueError(
+ "No VAE source provided. Standalone safetensors/GGUF models require a separate VAE. "
+ "Options:\n"
+ " 1. Set 'VAE' to a standalone FLUX VAE model\n"
+ " 2. Set 'Qwen3 Source' to a Diffusers Flux2 Klein model to extract the VAE from"
+ )
+
+ # Determine Qwen3 Encoder source
+ if self.qwen3_encoder_model is not None:
+ # Use standalone Qwen3 Encoder - validate it matches the FLUX.2 Klein variant
+ self._validate_qwen3_encoder_variant(context, main_config)
+ qwen3_tokenizer = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif main_is_diffusers:
+ # Extract from main model (recommended for FLUX.2 Klein)
+ qwen3_tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif self.qwen3_source_model is not None:
+ # Extract from separate Diffusers model
+ self._validate_diffusers_format(context, self.qwen3_source_model, "Qwen3 Source")
+ qwen3_tokenizer = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ else:
+ raise ValueError(
+ "No Qwen3 Encoder source provided. Standalone safetensors/GGUF models require a separate text encoder. "
+ "Options:\n"
+ " 1. Set 'Qwen3 Encoder' to a standalone Qwen3 text encoder model "
+ "(Klein 4B needs Qwen3 4B, Klein 9B needs Qwen3 8B)\n"
+ " 2. Set 'Qwen3 Source' to a Diffusers Flux2 Klein model to extract the encoder from"
+ )
+
+ return Flux2KleinModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ qwen3_encoder=Qwen3EncoderField(tokenizer=qwen3_tokenizer, text_encoder=qwen3_encoder),
+ vae=VAEField(vae=vae),
+ max_seq_len=self.max_seq_len,
+ )
+
+ def _validate_diffusers_format(
+ self, context: InvocationContext, model: ModelIdentifierField, model_name: str
+ ) -> None:
+ """Validate that a model is in Diffusers format."""
+ config = context.models.get_config(model)
+ if config.format != ModelFormat.Diffusers:
+ raise ValueError(
+ f"The {model_name} model must be a Diffusers format model. "
+ f"The selected model '{config.name}' is in {config.format.value} format."
+ )
+
+ def _validate_qwen3_encoder_variant(self, context: InvocationContext, main_config) -> None:
+ """Validate that the standalone Qwen3 encoder variant matches the FLUX.2 Klein variant.
+
+ - FLUX.2 Klein 4B requires Qwen3 4B encoder
+ - FLUX.2 Klein 9B requires Qwen3 8B encoder
+ """
+ if self.qwen3_encoder_model is None:
+ return
+
+ # Get the Qwen3 encoder config
+ qwen3_config = context.models.get_config(self.qwen3_encoder_model)
+
+ # Check if the config has a variant field
+ if not hasattr(qwen3_config, "variant"):
+ # Can't validate, skip
+ return
+
+ qwen3_variant = qwen3_config.variant
+
+ # Get the FLUX.2 Klein variant from the main model config
+ if not hasattr(main_config, "variant"):
+ return
+
+ flux2_variant = main_config.variant
+
+ # Validate the variants match
+ # Klein4B/Klein4BBase requires Qwen3_4B, Klein9B/Klein9BBase requires Qwen3_8B
+ expected_qwen3_variant = None
+ if flux2_variant in (Flux2VariantType.Klein4B, Flux2VariantType.Klein4BBase):
+ expected_qwen3_variant = Qwen3VariantType.Qwen3_4B
+ elif flux2_variant in (Flux2VariantType.Klein9B, Flux2VariantType.Klein9BBase):
+ expected_qwen3_variant = Qwen3VariantType.Qwen3_8B
+
+ if expected_qwen3_variant is not None and qwen3_variant != expected_qwen3_variant:
+ raise ValueError(
+ f"Qwen3 encoder variant mismatch: FLUX.2 Klein {flux2_variant.value} requires "
+ f"{expected_qwen3_variant.value} encoder, but {qwen3_variant.value} was selected. "
+ f"Please select a matching Qwen3 encoder or use a Diffusers format model which includes the correct encoder."
+ )
diff --git a/invokeai/app/invocations/flux2_klein_text_encoder.py b/invokeai/app/invocations/flux2_klein_text_encoder.py
new file mode 100644
index 00000000000..2b4b53faf72
--- /dev/null
+++ b/invokeai/app/invocations/flux2_klein_text_encoder.py
@@ -0,0 +1,201 @@
+"""Flux2 Klein Text Encoder Invocation.
+
+Flux2 Klein uses Qwen3 as the text encoder instead of CLIP+T5.
+The key difference is that it extracts hidden states from layers (9, 18, 27)
+and stacks them together for richer text representations.
+
+This implementation matches the diffusers Flux2KleinPipeline exactly.
+"""
+
+from contextlib import ExitStack
+from typing import Iterator, Literal, Optional, Tuple
+
+import torch
+from transformers import PreTrainedModel, PreTrainedTokenizerBase
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ FluxConditioningField,
+ Input,
+ InputField,
+ TensorField,
+ UIComponent,
+)
+from invokeai.app.invocations.model import Qwen3EncoderField
+from invokeai.app.invocations.primitives import FluxConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_T5_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, FLUXConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+# FLUX.2 Klein extracts hidden states from these specific layers
+# Matching diffusers Flux2KleinPipeline: (9, 18, 27)
+# hidden_states[0] is embedding layer, so layer N is at index N
+KLEIN_EXTRACTION_LAYERS = (9, 18, 27)
+
+# Default max sequence length for Klein models
+KLEIN_MAX_SEQ_LEN = 512
+
+
+@invocation(
+ "flux2_klein_text_encoder",
+ title="Prompt - Flux2 Klein",
+ tags=["prompt", "conditioning", "flux", "klein", "qwen3"],
+ category="prompt",
+ version="1.1.1",
+ classification=Classification.Prototype,
+ idle_gpu_offloadable=True,
+)
+class Flux2KleinTextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for Flux2 Klein image generation.
+
+ Flux2 Klein uses Qwen3 as the text encoder, extracting hidden states from
+ layers (9, 18, 27) and stacking them for richer text representations.
+ This matches the diffusers Flux2KleinPipeline implementation exactly.
+ """
+
+ prompt: str = InputField(description="Text prompt to encode.", ui_component=UIComponent.Textarea)
+ qwen3_encoder: Qwen3EncoderField = InputField(
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+ max_seq_len: Literal[256, 512] = InputField(
+ default=512,
+ description="Max sequence length for the Qwen3 encoder.",
+ )
+ mask: Optional[TensorField] = InputField(
+ default=None,
+ description="A mask defining the region that this conditioning prompt applies to.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> FluxConditioningOutput:
+ # Open the exitstack here to lock models for the duration of the node
+ with ExitStack() as exit_stack:
+ # Pass the locked stack down to the helper function
+ qwen3_embeds, pooled_embeds = self._encode_prompt(context, exit_stack)
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[FLUXConditioningInfo(clip_embeds=pooled_embeds, t5_embeds=qwen3_embeds)]
+ )
+
+ # The models are still locked while we save the data
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return FluxConditioningOutput(
+ conditioning=FluxConditioningField(conditioning_name=conditioning_name, mask=self.mask)
+ )
+
+ def _encode_prompt(self, context: InvocationContext, exit_stack: ExitStack) -> Tuple[torch.Tensor, torch.Tensor]:
+ prompt = self.prompt
+
+ # Reordered loading to prevent the annoying cache drop issue
+ # This prevents it from being evicted while we look up the tokenizer
+ text_encoder_info = context.models.load(self.qwen3_encoder.text_encoder)
+ (cached_weights, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
+
+ # Now it is safe to load and lock the tokenizer
+ tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
+ (_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
+
+ repaired_tensors = text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required Qwen3 tensor(s) onto {device} after a partial device mismatch."
+ )
+
+ # Apply LoRA models
+ lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=text_encoder,
+ patches=self._lora_iterator(context),
+ prefix=FLUX_LORA_T5_PREFIX,
+ dtype=lora_dtype,
+ cached_weights=cached_weights,
+ )
+ )
+
+ context.util.signal_progress("Running Qwen3 text encoder (Klein)")
+
+ if not isinstance(text_encoder, PreTrainedModel):
+ raise TypeError(
+ f"Expected PreTrainedModel for text encoder, got {type(text_encoder).__name__}. "
+ "The Qwen3 encoder model may be corrupted or incompatible."
+ )
+ if not isinstance(tokenizer, PreTrainedTokenizerBase):
+ raise TypeError(
+ f"Expected PreTrainedTokenizerBase for tokenizer, got {type(tokenizer).__name__}. "
+ "The Qwen3 tokenizer may be corrupted or incompatible."
+ )
+
+ messages = [{"role": "user", "content": prompt}]
+
+ text: str = tokenizer.apply_chat_template( # type: ignore[assignment]
+ messages,
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=False,
+ )
+
+ inputs = tokenizer(
+ text,
+ return_tensors="pt",
+ padding="max_length",
+ truncation=True,
+ max_length=self.max_seq_len,
+ )
+
+ input_ids = inputs["input_ids"].to(device)
+ attention_mask = inputs["attention_mask"].to(device)
+
+ # Forward pass through the model
+ outputs = text_encoder(
+ input_ids=input_ids,
+ attention_mask=attention_mask,
+ output_hidden_states=True,
+ use_cache=False,
+ )
+ if not hasattr(outputs, "hidden_states") or outputs.hidden_states is None:
+ raise RuntimeError(
+ "Text encoder did not return hidden_states. "
+ "Ensure output_hidden_states=True is supported by this model."
+ )
+ num_hidden_layers = len(outputs.hidden_states)
+
+ hidden_states_list = []
+ for layer_idx in KLEIN_EXTRACTION_LAYERS:
+ if layer_idx >= num_hidden_layers:
+ layer_idx = num_hidden_layers - 1
+ hidden_states_list.append(outputs.hidden_states[layer_idx])
+
+ out = torch.stack(hidden_states_list, dim=1)
+ out = out.to(dtype=text_encoder.dtype, device=device)
+
+ batch_size, num_channels, seq_len, hidden_dim = out.shape
+ prompt_embeds = out.permute(0, 2, 1, 3).reshape(batch_size, seq_len, num_channels * hidden_dim)
+
+ last_hidden_state = outputs.hidden_states[-1]
+ expanded_mask = attention_mask.unsqueeze(-1).expand_as(last_hidden_state).float()
+ sum_embeds = (last_hidden_state * expanded_mask).sum(dim=1)
+ num_tokens = expanded_mask.sum(dim=1).clamp(min=1)
+ pooled_embeds = sum_embeds / num_tokens
+
+ return prompt_embeds, pooled_embeds
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the Qwen3 text encoder."""
+ for lora in self.qwen3_encoder.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}. "
+ "The LoRA model may be corrupted or incompatible."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/flux2_vae_decode.py b/invokeai/app/invocations/flux2_vae_decode.py
new file mode 100644
index 00000000000..ecbc7d9cb83
--- /dev/null
+++ b/invokeai/app/invocations/flux2_vae_decode.py
@@ -0,0 +1,92 @@
+"""Flux2 Klein VAE Decode Invocation.
+
+Decodes latents to images using the FLUX.2 32-channel VAE (AutoencoderKLFlux2).
+"""
+
+import torch
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "flux2_vae_decode",
+ title="Latents to Image - FLUX2",
+ tags=["latents", "image", "vae", "l2i", "flux2", "klein"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class Flux2VaeDecodeInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents using FLUX.2 Klein's 32-channel VAE."""
+
+ latents: LatentsField = InputField(
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ vae: VAEField = InputField(
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+
+ def _vae_decode(self, vae_info: LoadedModel, latents: torch.Tensor) -> Image.Image:
+ """Decode latents to image using FLUX.2 VAE.
+
+ Input latents should already be in the correct space after BN denormalization
+ was applied in the denoiser. The VAE expects (B, 32, H, W) format.
+ """
+ with vae_info.model_on_device() as (_, vae):
+ vae_dtype = next(iter(vae.parameters())).dtype
+ device = TorchDevice.choose_torch_device()
+ latents = latents.to(device=device, dtype=vae_dtype)
+
+ # Decode using diffusers API
+ decoded = vae.decode(latents, return_dict=False)[0]
+
+ # Convert from [-1, 1] to [0, 1] then to [0, 255] PIL image
+ img = (decoded / 2 + 0.5).clamp(0, 1)
+ img = rearrange(img[0], "c h w -> h w c")
+ img_np = (img * 255).byte().cpu().numpy()
+ # Explicitly create RGB image (not grayscale)
+ img_pil = Image.fromarray(img_np, mode="RGB")
+ return img_pil
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ # Log latent statistics for debugging black image issues
+ context.logger.debug(
+ f"FLUX.2 VAE decode input: shape={latents.shape}, "
+ f"min={latents.min().item():.4f}, max={latents.max().item():.4f}, "
+ f"mean={latents.mean().item():.4f}"
+ )
+
+ # Warn if input latents are all zeros or very small (would cause black images)
+ if latents.abs().max() < 1e-6:
+ context.logger.warning(
+ "FLUX.2 VAE decode received near-zero latents! This will cause black images. "
+ "The latent cache may be corrupted - try clearing the cache."
+ )
+
+ vae_info = context.models.load(self.vae.vae)
+ context.util.signal_progress("Running VAE")
+ image = self._vae_decode(vae_info=vae_info, latents=latents)
+
+ TorchDevice.empty_cache()
+ image_dto = context.images.save(image=image)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/flux2_vae_encode.py b/invokeai/app/invocations/flux2_vae_encode.py
new file mode 100644
index 00000000000..1b43483a408
--- /dev/null
+++ b/invokeai/app/invocations/flux2_vae_encode.py
@@ -0,0 +1,88 @@
+"""Flux2 Klein VAE Encode Invocation.
+
+Encodes images to latents using the FLUX.2 32-channel VAE (AutoencoderKLFlux2).
+"""
+
+import einops
+import torch
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "flux2_vae_encode",
+ title="Image to Latents - FLUX2",
+ tags=["latents", "image", "vae", "i2l", "flux2", "klein"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class Flux2VaeEncodeInvocation(BaseInvocation):
+ """Encodes an image into latents using FLUX.2 Klein's 32-channel VAE."""
+
+ image: ImageField = InputField(
+ description="The image to encode.",
+ )
+ vae: VAEField = InputField(
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+
+ def _vae_encode(self, vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ """Encode image to latents using FLUX.2 VAE.
+
+ The VAE encodes to 32-channel latent space.
+ Output latents shape: (B, 32, H/8, W/8).
+ """
+ with vae_info.model_on_device() as (_, vae):
+ vae_dtype = next(iter(vae.parameters())).dtype
+ device = TorchDevice.choose_torch_device()
+ image_tensor = image_tensor.to(device=device, dtype=vae_dtype)
+
+ # Encode using diffusers API
+ # The VAE.encode() returns a DiagonalGaussianDistribution-like object
+ latent_dist = vae.encode(image_tensor, return_dict=False)[0]
+
+ # Sample from the distribution (or use mode for deterministic output)
+ # Using mode() for deterministic encoding
+ if hasattr(latent_dist, "mode"):
+ latents = latent_dist.mode()
+ elif hasattr(latent_dist, "sample"):
+ # Fall back to sampling if mode is not available
+ generator = torch.Generator(device=device).manual_seed(0)
+ latents = latent_dist.sample(generator=generator)
+ else:
+ # Direct tensor output (some VAE implementations)
+ latents = latent_dist
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ vae_info = context.models.load(self.vae.vae)
+
+ # Convert image to tensor (HWC -> CHW, normalize to [-1, 1])
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ context.util.signal_progress("Running VAE Encode")
+ latents = self._vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/flux_control_lora_loader.py b/invokeai/app/invocations/flux_control_lora_loader.py
new file mode 100644
index 00000000000..25025488667
--- /dev/null
+++ b/invokeai/app/invocations/flux_control_lora_loader.py
@@ -0,0 +1,51 @@
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, OutputField
+from invokeai.app.invocations.model import ControlLoRAField, ModelIdentifierField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("flux_control_lora_loader_output")
+class FluxControlLoRALoaderOutput(BaseInvocationOutput):
+ """Flux Control LoRA Loader Output"""
+
+ control_lora: ControlLoRAField = OutputField(
+ title="Flux Control LoRA", description="Control LoRAs to apply on model loading", default=None
+ )
+
+
+@invocation(
+ "flux_control_lora_loader",
+ title="Control LoRA - FLUX",
+ tags=["lora", "model", "flux"],
+ category="model",
+ version="1.1.1",
+)
+class FluxControlLoRALoaderInvocation(BaseInvocation):
+ """LoRA model and Image to use with FLUX transformer generation."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.control_lora_model,
+ title="Control LoRA",
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.ControlLoRa,
+ )
+ image: ImageField = InputField(description="The image to encode.")
+ weight: float = InputField(description="The weight of the LoRA.", default=1.0)
+
+ def invoke(self, context: InvocationContext) -> FluxControlLoRALoaderOutput:
+ if not context.models.exists(self.lora.key):
+ raise ValueError(f"Unknown lora: {self.lora.key}!")
+
+ return FluxControlLoRALoaderOutput(
+ control_lora=ControlLoRAField(
+ lora=self.lora,
+ img=self.image,
+ weight=self.weight,
+ )
+ )
diff --git a/invokeai/app/invocations/flux_controlnet.py b/invokeai/app/invocations/flux_controlnet.py
new file mode 100644
index 00000000000..b11d497f31f
--- /dev/null
+++ b/invokeai/app/invocations/flux_controlnet.py
@@ -0,0 +1,100 @@
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, OutputField
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+class FluxControlNetField(BaseModel):
+ image: ImageField = Field(description="The control image")
+ control_model: ModelIdentifierField = Field(description="The ControlNet model to use")
+ control_weight: float | list[float] = Field(default=1, description="The weight given to the ControlNet")
+ begin_step_percent: float = Field(
+ default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
+ )
+ end_step_percent: float = Field(
+ default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
+ )
+ resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use")
+ instantx_control_mode: int | None = Field(default=-1, description=FieldDescriptions.instantx_control_mode)
+
+ @field_validator("control_weight")
+ @classmethod
+ def validate_control_weight(cls, v: float | list[float]) -> float | list[float]:
+ validate_weights(v)
+ return v
+
+ @model_validator(mode="after")
+ def validate_begin_end_step_percent(self):
+ validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
+ return self
+
+
+@invocation_output("flux_controlnet_output")
+class FluxControlNetOutput(BaseInvocationOutput):
+ """FLUX ControlNet info"""
+
+ control: FluxControlNetField = OutputField(description=FieldDescriptions.control)
+
+
+@invocation(
+ "flux_controlnet",
+ title="FLUX ControlNet",
+ tags=["controlnet", "flux"],
+ category="conditioning",
+ version="1.0.0",
+)
+class FluxControlNetInvocation(BaseInvocation):
+ """Collect FLUX ControlNet info to pass to other nodes."""
+
+ image: ImageField = InputField(description="The control image")
+ control_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.controlnet_model,
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.ControlNet,
+ )
+ control_weight: float | list[float] = InputField(
+ default=1.0, ge=-1, le=2, description="The weight given to the ControlNet"
+ )
+ begin_step_percent: float = InputField(
+ default=0, ge=0, le=1, description="When the ControlNet is first applied (% of total steps)"
+ )
+ end_step_percent: float = InputField(
+ default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)"
+ )
+ resize_mode: CONTROLNET_RESIZE_VALUES = InputField(default="just_resize", description="The resize mode used")
+ # Note: We default to -1 instead of None, because in the workflow editor UI None is not currently supported.
+ instantx_control_mode: int | None = InputField(default=-1, description=FieldDescriptions.instantx_control_mode)
+
+ @field_validator("control_weight")
+ @classmethod
+ def validate_control_weight(cls, v: float | list[float]) -> float | list[float]:
+ validate_weights(v)
+ return v
+
+ @model_validator(mode="after")
+ def validate_begin_end_step_percent(self):
+ validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
+ return self
+
+ def invoke(self, context: InvocationContext) -> FluxControlNetOutput:
+ return FluxControlNetOutput(
+ control=FluxControlNetField(
+ image=self.image,
+ control_model=self.control_model,
+ control_weight=self.control_weight,
+ begin_step_percent=self.begin_step_percent,
+ end_step_percent=self.end_step_percent,
+ resize_mode=self.resize_mode,
+ instantx_control_mode=self.instantx_control_mode,
+ ),
+ )
diff --git a/invokeai/app/invocations/flux_denoise.py b/invokeai/app/invocations/flux_denoise.py
new file mode 100644
index 00000000000..06147229232
--- /dev/null
+++ b/invokeai/app/invocations/flux_denoise.py
@@ -0,0 +1,1019 @@
+from contextlib import ExitStack
+from typing import Callable, Iterator, Optional, Tuple, Union
+
+import einops
+import numpy as np
+import numpy.typing as npt
+import torch
+import torchvision.transforms as tv_transforms
+from PIL import Image
+from torchvision.transforms.functional import resize as tv_resize
+from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ FluxConditioningField,
+ FluxFillConditioningField,
+ FluxKontextConditioningField,
+ FluxReduxConditioningField,
+ ImageField,
+ Input,
+ InputField,
+ LatentsField,
+)
+from invokeai.app.invocations.flux_controlnet import FluxControlNetField
+from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation
+from invokeai.app.invocations.ip_adapter import IPAdapterField
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import ControlLoRAField, LoRAField, TransformerField, VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.controlnet.instantx_controlnet_flux import InstantXControlNetFlux
+from invokeai.backend.flux.controlnet.xlabs_controlnet_flux import XLabsControlNetFlux
+from invokeai.backend.flux.denoise import denoise
+from invokeai.backend.flux.dype.presets import (
+ DYPE_PRESET_LABELS,
+ DYPE_PRESET_OFF,
+ DyPEPreset,
+ get_dype_config_from_preset,
+)
+from invokeai.backend.flux.extensions.dype_extension import DyPEExtension
+from invokeai.backend.flux.extensions.instantx_controlnet_extension import InstantXControlNetExtension
+from invokeai.backend.flux.extensions.kontext_extension import KontextExtension
+from invokeai.backend.flux.extensions.regional_prompting_extension import RegionalPromptingExtension
+from invokeai.backend.flux.extensions.xlabs_controlnet_extension import XLabsControlNetExtension
+from invokeai.backend.flux.extensions.xlabs_ip_adapter_extension import XLabsIPAdapterExtension
+from invokeai.backend.flux.ip_adapter.xlabs_ip_adapter_flux import XlabsIpAdapterFlux
+from invokeai.backend.flux.model import Flux
+from invokeai.backend.flux.sampling_utils import (
+ clip_timestep_schedule_fractional,
+ generate_img_ids,
+ get_noise,
+ get_schedule,
+ pack,
+ unpack,
+)
+from invokeai.backend.flux.schedulers import FLUX_SCHEDULER_LABELS, FLUX_SCHEDULER_MAP, FLUX_SCHEDULER_NAME_VALUES
+from invokeai.backend.flux.text_conditioning import FluxReduxConditioning, FluxTextConditioning
+from invokeai.backend.model_manager.taxonomy import BaseModelType, FluxVariantType, ModelFormat, ModelType
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "flux_denoise",
+ title="FLUX Denoise",
+ tags=["image", "flux"],
+ category="latents",
+ version="4.6.0",
+)
+class FluxDenoiseInvocation(BaseInvocation):
+ """Run denoising process with a FLUX transformer model."""
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None,
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None,
+ description=FieldDescriptions.noise,
+ input=Input.Connection,
+ )
+ # denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None,
+ description=FieldDescriptions.denoise_mask,
+ input=Input.Connection,
+ )
+ denoising_start: float = InputField(
+ default=0.0,
+ ge=0,
+ le=1,
+ description=FieldDescriptions.denoising_start,
+ )
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ add_noise: bool = InputField(default=True, description="Add noise based on denoising start.")
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.flux_model,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ control_lora: Optional[ControlLoRAField] = InputField(
+ description=FieldDescriptions.control_lora_model, input=Input.Connection, title="Control LoRA", default=None
+ )
+ positive_text_conditioning: FluxConditioningField | list[FluxConditioningField] = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_text_conditioning: FluxConditioningField | list[FluxConditioningField] | None = InputField(
+ default=None,
+ description="Negative conditioning tensor. Can be None if cfg_scale is 1.0.",
+ input=Input.Connection,
+ )
+ redux_conditioning: FluxReduxConditioningField | list[FluxReduxConditioningField] | None = InputField(
+ default=None,
+ description="FLUX Redux conditioning tensor.",
+ input=Input.Connection,
+ )
+ fill_conditioning: FluxFillConditioningField | None = InputField(
+ default=None,
+ description="FLUX Fill conditioning.",
+ input=Input.Connection,
+ )
+ cfg_scale: float | list[float] = InputField(default=1.0, description=FieldDescriptions.cfg_scale, title="CFG Scale")
+ cfg_scale_start_step: int = InputField(
+ default=0,
+ title="CFG Scale Start Step",
+ description="Index of the first step to apply cfg_scale. Negative indices count backwards from the "
+ + "the last step (e.g. a value of -1 refers to the final step).",
+ )
+ cfg_scale_end_step: int = InputField(
+ default=-1,
+ title="CFG Scale End Step",
+ description="Index of the last step to apply cfg_scale. Negative indices count backwards from the "
+ + "last step (e.g. a value of -1 refers to the final step).",
+ )
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ num_steps: int = InputField(
+ default=4, description="Number of diffusion steps. Recommended values are schnell: 4, dev: 50."
+ )
+ scheduler: FLUX_SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description="Scheduler (sampler) for the denoising process. 'euler' is fast and standard. "
+ "'heun' is 2nd-order (better quality, 2x slower). 'lcm' is optimized for few steps.",
+ ui_choice_labels=FLUX_SCHEDULER_LABELS,
+ )
+ guidance: float = InputField(
+ default=4.0,
+ description="The guidance strength. Higher values adhere more strictly to the prompt, and will produce less diverse images. FLUX dev only, ignored for schnell.",
+ )
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+ control: FluxControlNetField | list[FluxControlNetField] | None = InputField(
+ default=None, input=Input.Connection, description="ControlNet models."
+ )
+ controlnet_vae: VAEField | None = InputField(
+ default=None,
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+ # This node accepts a images for features like FLUX Fill, ControlNet, and Kontext, but needs to operate on them in
+ # latent space. We'll run the VAE to encode them in this node instead of requiring the user to run the VAE in
+ # upstream nodes.
+
+ ip_adapter: IPAdapterField | list[IPAdapterField] | None = InputField(
+ description=FieldDescriptions.ip_adapter, title="IP-Adapter", default=None, input=Input.Connection
+ )
+
+ kontext_conditioning: FluxKontextConditioningField | list[FluxKontextConditioningField] | None = InputField(
+ default=None,
+ description="FLUX Kontext conditioning (reference image).",
+ input=Input.Connection,
+ )
+
+ # DyPE (Dynamic Position Extrapolation) for high-resolution generation
+ dype_preset: DyPEPreset = InputField(
+ default=DYPE_PRESET_OFF,
+ description=(
+ "DyPE preset for high-resolution generation. 'auto' enables automatically for resolutions > 1536px. "
+ "'area' enables automatically based on image area. '4k' uses optimized settings for 4K output."
+ ),
+ ui_order=100,
+ ui_choice_labels=DYPE_PRESET_LABELS,
+ )
+ dype_scale: Optional[float] = InputField(
+ default=None,
+ ge=0.0,
+ le=8.0,
+ description="DyPE magnitude (λs). Higher values = stronger extrapolation. Only used when dype_preset is not 'off'.",
+ ui_order=101,
+ )
+ dype_exponent: Optional[float] = InputField(
+ default=None,
+ ge=0.0,
+ le=1000.0,
+ description="DyPE decay speed (λt). Controls transition from low to high frequency detail. Only used when dype_preset is not 'off'.",
+ ui_order=102,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _run_diffusion(
+ self,
+ context: InvocationContext,
+ ):
+ inference_dtype = torch.bfloat16
+ device = TorchDevice.choose_torch_device()
+
+ # Load the input latents, if provided.
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+
+ # Prepare input noise.
+ # If noise will never be consumed, avoid validating/loading it.
+ should_ignore_noise = init_latents is not None and not self.add_noise and self.denoise_mask is None
+ noise: Optional[torch.Tensor]
+ if should_ignore_noise:
+ noise = None
+ b, _c, latent_h, latent_w = init_latents.shape
+ else:
+ noise = self._prepare_noise_tensor(context, inference_dtype, device)
+ b, _c, latent_h, latent_w = noise.shape
+ packed_h = latent_h // 2
+ packed_w = latent_w // 2
+
+ # Load the conditioning data.
+ pos_text_conditionings = self._load_text_conditioning(
+ context=context,
+ cond_field=self.positive_text_conditioning,
+ packed_height=packed_h,
+ packed_width=packed_w,
+ dtype=inference_dtype,
+ device=device,
+ )
+ neg_text_conditionings: list[FluxTextConditioning] | None = None
+ if self.negative_text_conditioning is not None:
+ neg_text_conditionings = self._load_text_conditioning(
+ context=context,
+ cond_field=self.negative_text_conditioning,
+ packed_height=packed_h,
+ packed_width=packed_w,
+ dtype=inference_dtype,
+ device=device,
+ )
+ redux_conditionings: list[FluxReduxConditioning] = self._load_redux_conditioning(
+ context=context,
+ redux_cond_field=self.redux_conditioning,
+ packed_height=packed_h,
+ packed_width=packed_w,
+ device=device,
+ dtype=inference_dtype,
+ )
+ pos_regional_prompting_extension = RegionalPromptingExtension.from_text_conditioning(
+ text_conditioning=pos_text_conditionings,
+ redux_conditioning=redux_conditionings,
+ img_seq_len=packed_h * packed_w,
+ )
+ neg_regional_prompting_extension = (
+ RegionalPromptingExtension.from_text_conditioning(
+ text_conditioning=neg_text_conditionings, redux_conditioning=[], img_seq_len=packed_h * packed_w
+ )
+ if neg_text_conditionings
+ else None
+ )
+
+ transformer_config = context.models.get_config(self.transformer.transformer)
+ assert (
+ transformer_config.base in (BaseModelType.Flux, BaseModelType.Flux2)
+ and transformer_config.type is ModelType.Main
+ )
+ # Schnell is only for FLUX.1, FLUX.2 Klein behaves like Dev (with guidance)
+ is_schnell = (
+ transformer_config.base is BaseModelType.Flux and transformer_config.variant is FluxVariantType.Schnell
+ )
+
+ # Calculate the timestep schedule.
+ timesteps = get_schedule(
+ num_steps=self.num_steps,
+ image_seq_len=packed_h * packed_w,
+ shift=not is_schnell,
+ )
+
+ # Create scheduler if not using default euler
+ scheduler = None
+ if self.scheduler in FLUX_SCHEDULER_MAP:
+ scheduler_class = FLUX_SCHEDULER_MAP[self.scheduler]
+ scheduler = scheduler_class(num_train_timesteps=1000)
+
+ # Clip the timesteps schedule based on denoising_start and denoising_end.
+ timesteps = clip_timestep_schedule_fractional(timesteps, self.denoising_start, self.denoising_end)
+
+ # Prepare input latent image.
+ if init_latents is not None:
+ # If init_latents is provided, we are doing image-to-image.
+
+ if is_schnell:
+ context.logger.warning(
+ "Running image-to-image with a FLUX schnell model. This is not recommended. The results are likely "
+ "to be poor. Consider using a FLUX dev model instead."
+ )
+
+ if self.add_noise:
+ assert noise is not None
+ # Noise the orig_latents by the appropriate amount for the first
+ # timestep in InvokeAI's clipped schedule.
+ #
+ # Known limitation: if the selected scheduler later replaces this
+ # schedule with its own first effective timestep/sigma (for example
+ # Heun internal expansion or LCM's scheduler-defined schedule), the
+ # img2img preblend below may not match that scheduler's true first
+ # step exactly. This is an existing pipeline limitation and affects
+ # both internally generated noise and externally supplied noise.
+ t_0 = timesteps[0]
+ x = t_0 * noise + (1.0 - t_0) * init_latents
+ else:
+ x = init_latents
+ else:
+ # init_latents are not provided, so we are not doing image-to-image (i.e. we are starting from pure noise).
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+
+ assert noise is not None
+ x = noise
+
+ # If len(timesteps) == 1, then short-circuit. We are just noising the input latents, but not taking any
+ # denoising steps.
+ if len(timesteps) <= 1:
+ return x
+
+ if is_schnell and self.control_lora:
+ raise ValueError("Control LoRAs cannot be used with FLUX Schnell")
+
+ # Prepare the extra image conditioning tensor (img_cond) for either FLUX structural control or FLUX Fill.
+ img_cond: torch.Tensor | None = None
+ is_flux_fill = transformer_config.variant is FluxVariantType.DevFill
+ if is_flux_fill:
+ img_cond = self._prep_flux_fill_img_cond(context, device=device, dtype=inference_dtype)
+ else:
+ if self.fill_conditioning is not None:
+ raise ValueError("fill_conditioning was provided, but the model is not a FLUX Fill model.")
+
+ if self.control_lora is not None:
+ img_cond = self._prep_structural_control_img_cond(context)
+
+ inpaint_mask = self._prep_inpaint_mask(context, x)
+
+ img_ids = generate_img_ids(h=latent_h, w=latent_w, batch_size=b, device=x.device, dtype=x.dtype)
+
+ # Pack all latent tensors.
+ init_latents = pack(init_latents) if init_latents is not None else None
+ inpaint_mask = pack(inpaint_mask) if inpaint_mask is not None else None
+ noise = pack(noise)
+ x = pack(x)
+
+ # Now that we have 'packed' the latent tensors, verify that we calculated the image_seq_len, packed_h, and
+ # packed_w correctly.
+ assert packed_h * packed_w == x.shape[1]
+
+ # Prepare inpaint extension.
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ assert init_latents is not None
+ assert noise is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents,
+ inpaint_mask=inpaint_mask,
+ noise=noise,
+ )
+
+ # Compute the IP-Adapter image prompt clip embeddings.
+ # We do this before loading other models to minimize peak memory.
+ # TODO(ryand): We should really do this in a separate invocation to benefit from caching.
+ ip_adapter_fields = self._normalize_ip_adapter_fields()
+ pos_image_prompt_clip_embeds, neg_image_prompt_clip_embeds = self._prep_ip_adapter_image_prompt_clip_embeds(
+ ip_adapter_fields, context, device=x.device
+ )
+
+ cfg_scale = self.prep_cfg_scale(
+ cfg_scale=self.cfg_scale,
+ timesteps=timesteps,
+ cfg_scale_start_step=self.cfg_scale_start_step,
+ cfg_scale_end_step=self.cfg_scale_end_step,
+ )
+
+ kontext_extension = None
+ if self.kontext_conditioning:
+ if not self.controlnet_vae:
+ raise ValueError("A VAE (e.g., controlnet_vae) must be provided to use Kontext conditioning.")
+
+ kontext_extension = KontextExtension(
+ context=context,
+ kontext_conditioning=self.kontext_conditioning
+ if isinstance(self.kontext_conditioning, list)
+ else [self.kontext_conditioning],
+ vae_field=self.controlnet_vae,
+ device=device,
+ dtype=inference_dtype,
+ )
+
+ with ExitStack() as exit_stack:
+ # Prepare ControlNet extensions.
+ # Note: We do this before loading the transformer model to minimize peak memory (see implementation).
+ controlnet_extensions = self._prep_controlnet_extensions(
+ context=context,
+ exit_stack=exit_stack,
+ latent_height=latent_h,
+ latent_width=latent_w,
+ dtype=inference_dtype,
+ device=x.device,
+ )
+
+ # Load the transformer model.
+ (cached_weights, transformer) = exit_stack.enter_context(
+ context.models.load(self.transformer.transformer).model_on_device()
+ )
+ assert isinstance(transformer, Flux)
+ config = transformer_config
+ assert config is not None
+
+ # Determine if the model is quantized.
+ # If the model is quantized, then we need to apply the LoRA weights as sidecar layers. This results in
+ # slower inference than direct patching, but is agnostic to the quantization format.
+ if config.format in [ModelFormat.Checkpoint]:
+ model_is_quantized = False
+ elif config.format in [
+ ModelFormat.BnbQuantizedLlmInt8b,
+ ModelFormat.BnbQuantizednf4b,
+ ModelFormat.GGUFQuantized,
+ ]:
+ model_is_quantized = True
+ else:
+ raise ValueError(f"Unsupported model format: {config.format}")
+
+ # Apply LoRA models to the transformer.
+ # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=transformer,
+ patches=self._lora_iterator(context),
+ prefix=FLUX_LORA_TRANSFORMER_PREFIX,
+ dtype=inference_dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=model_is_quantized,
+ )
+ )
+
+ # Prepare IP-Adapter extensions.
+ pos_ip_adapter_extensions, neg_ip_adapter_extensions = self._prep_ip_adapter_extensions(
+ pos_image_prompt_clip_embeds=pos_image_prompt_clip_embeds,
+ neg_image_prompt_clip_embeds=neg_image_prompt_clip_embeds,
+ ip_adapter_fields=ip_adapter_fields,
+ context=context,
+ exit_stack=exit_stack,
+ dtype=inference_dtype,
+ )
+
+ # Prepare Kontext conditioning if provided
+ img_cond_seq = None
+ img_cond_seq_ids = None
+ if kontext_extension is not None:
+ # Ensure batch sizes match
+ kontext_extension.ensure_batch_size(x.shape[0])
+ img_cond_seq, img_cond_seq_ids = kontext_extension.kontext_latents, kontext_extension.kontext_ids
+
+ # Prepare DyPE extension for high-resolution generation
+ dype_extension: DyPEExtension | None = None
+ dype_config = get_dype_config_from_preset(
+ preset=self.dype_preset,
+ width=self.width,
+ height=self.height,
+ custom_scale=self.dype_scale,
+ custom_exponent=self.dype_exponent,
+ )
+ if dype_config is not None:
+ dype_extension = DyPEExtension(
+ config=dype_config,
+ target_height=self.height,
+ target_width=self.width,
+ )
+ context.logger.info(
+ f"DyPE enabled: resolution={self.width}x{self.height}, preset={self.dype_preset}, "
+ f"scale={dype_config.dype_scale:.2f}, "
+ f"exponent={dype_config.dype_exponent:.2f}, start_sigma={dype_config.dype_start_sigma:.2f}, "
+ f"base_resolution={dype_config.base_resolution}"
+ )
+ else:
+ context.logger.debug(f"DyPE disabled: resolution={self.width}x{self.height}, preset={self.dype_preset}")
+
+ x = denoise(
+ model=transformer,
+ img=x,
+ img_ids=img_ids,
+ pos_regional_prompting_extension=pos_regional_prompting_extension,
+ neg_regional_prompting_extension=neg_regional_prompting_extension,
+ timesteps=timesteps,
+ step_callback=self._build_step_callback(context),
+ guidance=self.guidance,
+ cfg_scale=cfg_scale,
+ inpaint_extension=inpaint_extension,
+ controlnet_extensions=controlnet_extensions,
+ pos_ip_adapter_extensions=pos_ip_adapter_extensions,
+ neg_ip_adapter_extensions=neg_ip_adapter_extensions,
+ img_cond=img_cond,
+ img_cond_seq=img_cond_seq,
+ img_cond_seq_ids=img_cond_seq_ids,
+ dype_extension=dype_extension,
+ scheduler=scheduler,
+ )
+
+ x = unpack(x.float(), self.height, self.width)
+ return x
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "FLUX", self.width, self.height)
+ return noise
+
+ return get_noise(
+ num_samples=1,
+ height=self.height,
+ width=self.width,
+ device=device,
+ dtype=inference_dtype,
+ seed=self.seed,
+ )
+
+ def _load_text_conditioning(
+ self,
+ context: InvocationContext,
+ cond_field: FluxConditioningField | list[FluxConditioningField],
+ packed_height: int,
+ packed_width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> list[FluxTextConditioning]:
+ """Load text conditioning data from a FluxConditioningField or a list of FluxConditioningFields."""
+ # Normalize to a list of FluxConditioningFields.
+ cond_list = [cond_field] if isinstance(cond_field, FluxConditioningField) else cond_field
+
+ text_conditionings: list[FluxTextConditioning] = []
+ for cond_field in cond_list:
+ # Load the text embeddings.
+ cond_data = context.conditioning.load(cond_field.conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ flux_conditioning = cond_data.conditionings[0]
+ assert isinstance(flux_conditioning, FLUXConditioningInfo)
+ flux_conditioning = flux_conditioning.to(dtype=dtype, device=device)
+ t5_embeddings = flux_conditioning.t5_embeds
+ clip_embeddings = flux_conditioning.clip_embeds
+
+ # Load the mask, if provided.
+ mask: Optional[torch.Tensor] = None
+ if cond_field.mask is not None:
+ mask = context.tensors.load(cond_field.mask.tensor_name)
+ mask = mask.to(device=device)
+ mask = RegionalPromptingExtension.preprocess_regional_prompt_mask(
+ mask, packed_height, packed_width, dtype, device
+ )
+
+ text_conditionings.append(FluxTextConditioning(t5_embeddings, clip_embeddings, mask))
+
+ return text_conditionings
+
+ def _load_redux_conditioning(
+ self,
+ context: InvocationContext,
+ redux_cond_field: FluxReduxConditioningField | list[FluxReduxConditioningField] | None,
+ packed_height: int,
+ packed_width: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ ) -> list[FluxReduxConditioning]:
+ # Normalize to a list of FluxReduxConditioningFields.
+ if redux_cond_field is None:
+ return []
+
+ redux_cond_list = (
+ [redux_cond_field] if isinstance(redux_cond_field, FluxReduxConditioningField) else redux_cond_field
+ )
+
+ redux_conditionings: list[FluxReduxConditioning] = []
+ for redux_cond_field in redux_cond_list:
+ # Load the Redux conditioning tensor.
+ redux_cond_data = context.tensors.load(redux_cond_field.conditioning.tensor_name)
+ redux_cond_data.to(device=device, dtype=dtype)
+
+ # Load the mask, if provided.
+ mask: Optional[torch.Tensor] = None
+ if redux_cond_field.mask is not None:
+ mask = context.tensors.load(redux_cond_field.mask.tensor_name)
+ mask = mask.to(device=device)
+ mask = RegionalPromptingExtension.preprocess_regional_prompt_mask(
+ mask, packed_height, packed_width, dtype, device
+ )
+
+ redux_conditionings.append(FluxReduxConditioning(redux_embeddings=redux_cond_data, mask=mask))
+
+ return redux_conditionings
+
+ @classmethod
+ def prep_cfg_scale(
+ cls, cfg_scale: float | list[float], timesteps: list[float], cfg_scale_start_step: int, cfg_scale_end_step: int
+ ) -> list[float]:
+ """Prepare the cfg_scale schedule.
+
+ - Clips the cfg_scale schedule based on cfg_scale_start_step and cfg_scale_end_step.
+ - If cfg_scale is a list, then it is assumed to be a schedule and is returned as-is.
+ - If cfg_scale is a scalar, then a linear schedule is created from cfg_scale_start_step to cfg_scale_end_step.
+ """
+ # num_steps is the number of denoising steps, which is one less than the number of timesteps.
+ num_steps = len(timesteps) - 1
+
+ # Normalize cfg_scale to a list if it is a scalar.
+ cfg_scale_list: list[float]
+ if isinstance(cfg_scale, float):
+ cfg_scale_list = [cfg_scale] * num_steps
+ elif isinstance(cfg_scale, list):
+ cfg_scale_list = cfg_scale
+ else:
+ raise ValueError(f"Unsupported cfg_scale type: {type(cfg_scale)}")
+ assert len(cfg_scale_list) == num_steps
+
+ # Handle negative indices for cfg_scale_start_step and cfg_scale_end_step.
+ start_step_index = cfg_scale_start_step
+ if start_step_index < 0:
+ start_step_index = num_steps + start_step_index
+ end_step_index = cfg_scale_end_step
+ if end_step_index < 0:
+ end_step_index = num_steps + end_step_index
+
+ # Validate the start and end step indices.
+ if not (0 <= start_step_index < num_steps):
+ raise ValueError(f"Invalid cfg_scale_start_step. Out of range: {cfg_scale_start_step}.")
+ if not (0 <= end_step_index < num_steps):
+ raise ValueError(f"Invalid cfg_scale_end_step. Out of range: {cfg_scale_end_step}.")
+ if start_step_index > end_step_index:
+ raise ValueError(
+ f"cfg_scale_start_step ({cfg_scale_start_step}) must be before cfg_scale_end_step "
+ + f"({cfg_scale_end_step})."
+ )
+
+ # Set values outside the start and end step indices to 1.0. This is equivalent to disabling cfg_scale for those
+ # steps.
+ clipped_cfg_scale = [1.0] * num_steps
+ clipped_cfg_scale[start_step_index : end_step_index + 1] = cfg_scale_list[start_step_index : end_step_index + 1]
+
+ return clipped_cfg_scale
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ """Prepare the inpaint mask.
+
+ - Loads the mask
+ - Resizes if necessary
+ - Casts to same device/dtype as latents
+ - Expands mask to the same shape as latents so that they line up after 'packing'
+
+ Args:
+ context (InvocationContext): The invocation context, for loading the inpaint mask.
+ latents (torch.Tensor): A latent image tensor. In 'unpacked' format. Used to determine the target shape,
+ device, and dtype for the inpaint mask.
+
+ Returns:
+ torch.Tensor | None: Inpaint mask. Values of 0.0 represent the regions to be fully denoised, and 1.0
+ represent the regions to be preserved.
+ """
+ if self.denoise_mask is None:
+ return None
+
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+
+ # The input denoise_mask contains values in [0, 1], where 0.0 represents the regions to be fully denoised, and
+ # 1.0 represents the regions to be preserved.
+ # We invert the mask so that the regions to be preserved are 0.0 and the regions to be denoised are 1.0.
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+
+ # Expand the inpaint mask to the same shape as `latents` so that when we 'pack' `mask` it lines up with
+ # `latents`.
+ return mask.expand_as(latents)
+
+ def _prep_controlnet_extensions(
+ self,
+ context: InvocationContext,
+ exit_stack: ExitStack,
+ latent_height: int,
+ latent_width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> list[XLabsControlNetExtension | InstantXControlNetExtension]:
+ # Normalize the controlnet input to list[ControlField].
+ controlnets: list[FluxControlNetField]
+ if self.control is None:
+ controlnets = []
+ elif isinstance(self.control, FluxControlNetField):
+ controlnets = [self.control]
+ elif isinstance(self.control, list):
+ controlnets = self.control
+ else:
+ raise ValueError(f"Unsupported controlnet type: {type(self.control)}")
+
+ # TODO(ryand): Add a field to the model config so that we can distinguish between XLabs and InstantX ControlNets
+ # before loading the models. Then make sure that all VAE encoding is done before loading the ControlNets to
+ # minimize peak memory.
+
+ # Calculate the controlnet conditioning tensors.
+ # We do this before loading the ControlNet models because it may require running the VAE, and we are trying to
+ # keep peak memory down.
+ controlnet_conds: list[torch.Tensor] = []
+ for controlnet in controlnets:
+ image = context.images.get_pil(controlnet.image.image_name)
+
+ # HACK(ryand): We have to load the ControlNet model to determine whether the VAE needs to be run. We really
+ # shouldn't have to load the model here. There's a risk that the model will be dropped from the model cache
+ # before we load it into VRAM and thus we'll have to load it again (context:
+ # https://github.com/invoke-ai/InvokeAI/issues/7513).
+ controlnet_model = context.models.load(controlnet.control_model)
+ if isinstance(controlnet_model.model, InstantXControlNetFlux):
+ if self.controlnet_vae is None:
+ raise ValueError("A ControlNet VAE is required when using an InstantX FLUX ControlNet.")
+ vae_info = context.models.load(self.controlnet_vae.vae)
+ controlnet_conds.append(
+ InstantXControlNetExtension.prepare_controlnet_cond(
+ controlnet_image=image,
+ vae_info=vae_info,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ dtype=dtype,
+ device=device,
+ resize_mode=controlnet.resize_mode,
+ )
+ )
+ elif isinstance(controlnet_model.model, XLabsControlNetFlux):
+ controlnet_conds.append(
+ XLabsControlNetExtension.prepare_controlnet_cond(
+ controlnet_image=image,
+ latent_height=latent_height,
+ latent_width=latent_width,
+ dtype=dtype,
+ device=device,
+ resize_mode=controlnet.resize_mode,
+ )
+ )
+
+ # Finally, load the ControlNet models and initialize the ControlNet extensions.
+ controlnet_extensions: list[XLabsControlNetExtension | InstantXControlNetExtension] = []
+ for controlnet, controlnet_cond in zip(controlnets, controlnet_conds, strict=True):
+ model = exit_stack.enter_context(context.models.load(controlnet.control_model))
+
+ if isinstance(model, XLabsControlNetFlux):
+ controlnet_extensions.append(
+ XLabsControlNetExtension(
+ model=model,
+ controlnet_cond=controlnet_cond,
+ weight=controlnet.control_weight,
+ begin_step_percent=controlnet.begin_step_percent,
+ end_step_percent=controlnet.end_step_percent,
+ )
+ )
+ elif isinstance(model, InstantXControlNetFlux):
+ instantx_control_mode: torch.Tensor | None = None
+ if controlnet.instantx_control_mode is not None and controlnet.instantx_control_mode >= 0:
+ instantx_control_mode = torch.tensor(controlnet.instantx_control_mode, dtype=torch.long)
+ instantx_control_mode = instantx_control_mode.reshape([-1, 1])
+
+ controlnet_extensions.append(
+ InstantXControlNetExtension(
+ model=model,
+ controlnet_cond=controlnet_cond,
+ instantx_control_mode=instantx_control_mode,
+ weight=controlnet.control_weight,
+ begin_step_percent=controlnet.begin_step_percent,
+ end_step_percent=controlnet.end_step_percent,
+ )
+ )
+ else:
+ raise ValueError(f"Unsupported ControlNet model type: {type(model)}")
+
+ return controlnet_extensions
+
+ def _prep_structural_control_img_cond(self, context: InvocationContext) -> torch.Tensor | None:
+ if self.control_lora is None:
+ return None
+
+ if not self.controlnet_vae:
+ raise ValueError("controlnet_vae must be set when using a FLUX Control LoRA.")
+
+ # Load the conditioning image and resize it to the target image size.
+ cond_img = context.images.get_pil(self.control_lora.img.image_name)
+ cond_img = cond_img.convert("RGB")
+ cond_img = cond_img.resize((self.width, self.height), Image.Resampling.BICUBIC)
+ cond_img = np.array(cond_img)
+
+ # Normalize the conditioning image to the range [-1, 1].
+ # This normalization is based on the original implementations here:
+ # https://github.com/black-forest-labs/flux/blob/805da8571a0b49b6d4043950bd266a65328c243b/src/flux/modules/image_embedders.py#L34
+ # https://github.com/black-forest-labs/flux/blob/805da8571a0b49b6d4043950bd266a65328c243b/src/flux/modules/image_embedders.py#L60
+ img_cond = torch.from_numpy(cond_img).float() / 127.5 - 1.0
+ img_cond = einops.rearrange(img_cond, "h w c -> 1 c h w")
+
+ vae_info = context.models.load(self.controlnet_vae.vae)
+ img_cond = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=img_cond)
+
+ return pack(img_cond)
+
+ def _prep_flux_fill_img_cond(
+ self, context: InvocationContext, device: torch.device, dtype: torch.dtype
+ ) -> torch.Tensor:
+ """Prepare the FLUX Fill conditioning. This method should be called iff the model is a FLUX Fill model.
+
+ This logic is based on:
+ https://github.com/black-forest-labs/flux/blob/716724eb276d94397be99710a0a54d352664e23b/src/flux/sampling.py#L107-L157
+ """
+ # Validate inputs.
+ if self.fill_conditioning is None:
+ raise ValueError("A FLUX Fill model is being used without fill_conditioning.")
+ # TODO(ryand): We should probable rename controlnet_vae. It's used for more than just ControlNets.
+ if self.controlnet_vae is None:
+ raise ValueError("A FLUX Fill model is being used without controlnet_vae.")
+ if self.control_lora is not None:
+ raise ValueError(
+ "A FLUX Fill model is being used, but a control_lora was provided. Control LoRAs are not compatible with FLUX Fill models."
+ )
+
+ # Log input warnings related to FLUX Fill usage.
+ if self.denoise_mask is not None:
+ context.logger.warning(
+ "Both fill_conditioning and a denoise_mask were provided. You probably meant to use one or the other."
+ )
+ if self.guidance < 25.0:
+ context.logger.warning("A guidance value of ~30.0 is recommended for FLUX Fill models.")
+
+ # Load the conditioning image and resize it to the target image size.
+ cond_img = context.images.get_pil(self.fill_conditioning.image.image_name, mode="RGB")
+ cond_img = cond_img.resize((self.width, self.height), Image.Resampling.BICUBIC)
+ cond_img = np.array(cond_img)
+ cond_img = torch.from_numpy(cond_img).float() / 127.5 - 1.0
+ cond_img = einops.rearrange(cond_img, "h w c -> 1 c h w")
+ cond_img = cond_img.to(device=device, dtype=dtype)
+
+ # Load the mask and resize it to the target image size.
+ mask = context.tensors.load(self.fill_conditioning.mask.tensor_name)
+ # We expect mask to be a bool tensor with shape [1, H, W].
+ assert mask.dtype == torch.bool
+ assert mask.dim() == 3
+ assert mask.shape[0] == 1
+ mask = tv_resize(mask, size=[self.height, self.width], interpolation=tv_transforms.InterpolationMode.NEAREST)
+ mask = mask.to(device=device, dtype=dtype)
+ mask = einops.rearrange(mask, "1 h w -> 1 1 h w")
+
+ # Prepare image conditioning.
+ cond_img = cond_img * (1 - mask)
+ vae_info = context.models.load(self.controlnet_vae.vae)
+ cond_img = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=cond_img)
+ cond_img = pack(cond_img)
+
+ # Prepare mask conditioning.
+ mask = mask[:, 0, :, :]
+ # Rearrange mask to a 16-channel representation that matches the shape of the VAE-encoded latent space.
+ mask = einops.rearrange(mask, "b (h ph) (w pw) -> b (ph pw) h w", ph=8, pw=8)
+ mask = pack(mask)
+
+ # Merge image and mask conditioning.
+ img_cond = torch.cat((cond_img, mask), dim=-1)
+ return img_cond
+
+ def _normalize_ip_adapter_fields(self) -> list[IPAdapterField]:
+ if self.ip_adapter is None:
+ return []
+ elif isinstance(self.ip_adapter, IPAdapterField):
+ return [self.ip_adapter]
+ elif isinstance(self.ip_adapter, list):
+ return self.ip_adapter
+ else:
+ raise ValueError(f"Unsupported IP-Adapter type: {type(self.ip_adapter)}")
+
+ def _prep_ip_adapter_image_prompt_clip_embeds(
+ self,
+ ip_adapter_fields: list[IPAdapterField],
+ context: InvocationContext,
+ device: torch.device,
+ ) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
+ """Run the IPAdapter CLIPVisionModel, returning image prompt embeddings."""
+ clip_image_processor = CLIPImageProcessor()
+
+ pos_image_prompt_clip_embeds: list[torch.Tensor] = []
+ neg_image_prompt_clip_embeds: list[torch.Tensor] = []
+ for ip_adapter_field in ip_adapter_fields:
+ # `ip_adapter_field.image` could be a list or a single ImageField. Normalize to a list here.
+ ipa_image_fields: list[ImageField]
+ if isinstance(ip_adapter_field.image, ImageField):
+ ipa_image_fields = [ip_adapter_field.image]
+ elif isinstance(ip_adapter_field.image, list):
+ ipa_image_fields = ip_adapter_field.image
+ else:
+ raise ValueError(f"Unsupported IP-Adapter image type: {type(ip_adapter_field.image)}")
+
+ if len(ipa_image_fields) != 1:
+ raise ValueError(
+ f"FLUX IP-Adapter only supports a single image prompt (received {len(ipa_image_fields)})."
+ )
+
+ ipa_images = [context.images.get_pil(image.image_name, mode="RGB") for image in ipa_image_fields]
+
+ pos_images: list[npt.NDArray[np.uint8]] = []
+ neg_images: list[npt.NDArray[np.uint8]] = []
+ for ipa_image in ipa_images:
+ assert ipa_image.mode == "RGB"
+ pos_image = np.array(ipa_image)
+ # We use a black image as the negative image prompt for parity with
+ # https://github.com/XLabs-AI/x-flux-comfyui/blob/45c834727dd2141aebc505ae4b01f193a8414e38/nodes.py#L592-L593
+ # An alternative scheme would be to apply zeros_like() after calling the clip_image_processor.
+ neg_image = np.zeros_like(pos_image)
+ pos_images.append(pos_image)
+ neg_images.append(neg_image)
+
+ with context.models.load(ip_adapter_field.image_encoder_model) as image_encoder_model:
+ assert isinstance(image_encoder_model, CLIPVisionModelWithProjection)
+
+ clip_image: torch.Tensor = clip_image_processor(images=pos_images, return_tensors="pt").pixel_values
+ clip_image = clip_image.to(device=device, dtype=image_encoder_model.dtype)
+ pos_clip_image_embeds = image_encoder_model(clip_image).image_embeds
+
+ clip_image = clip_image_processor(images=neg_images, return_tensors="pt").pixel_values
+ clip_image = clip_image.to(device=device, dtype=image_encoder_model.dtype)
+ neg_clip_image_embeds = image_encoder_model(clip_image).image_embeds
+
+ pos_image_prompt_clip_embeds.append(pos_clip_image_embeds)
+ neg_image_prompt_clip_embeds.append(neg_clip_image_embeds)
+
+ return pos_image_prompt_clip_embeds, neg_image_prompt_clip_embeds
+
+ def _prep_ip_adapter_extensions(
+ self,
+ ip_adapter_fields: list[IPAdapterField],
+ pos_image_prompt_clip_embeds: list[torch.Tensor],
+ neg_image_prompt_clip_embeds: list[torch.Tensor],
+ context: InvocationContext,
+ exit_stack: ExitStack,
+ dtype: torch.dtype,
+ ) -> tuple[list[XLabsIPAdapterExtension], list[XLabsIPAdapterExtension]]:
+ pos_ip_adapter_extensions: list[XLabsIPAdapterExtension] = []
+ neg_ip_adapter_extensions: list[XLabsIPAdapterExtension] = []
+ for ip_adapter_field, pos_image_prompt_clip_embed, neg_image_prompt_clip_embed in zip(
+ ip_adapter_fields, pos_image_prompt_clip_embeds, neg_image_prompt_clip_embeds, strict=True
+ ):
+ ip_adapter_model = exit_stack.enter_context(context.models.load(ip_adapter_field.ip_adapter_model))
+ assert isinstance(ip_adapter_model, XlabsIpAdapterFlux)
+ ip_adapter_model = ip_adapter_model.to(dtype=dtype)
+ if ip_adapter_field.mask is not None:
+ raise ValueError("IP-Adapter masks are not yet supported in Flux.")
+ ip_adapter_extension = XLabsIPAdapterExtension(
+ model=ip_adapter_model,
+ image_prompt_clip_embed=pos_image_prompt_clip_embed,
+ weight=ip_adapter_field.weight,
+ begin_step_percent=ip_adapter_field.begin_step_percent,
+ end_step_percent=ip_adapter_field.end_step_percent,
+ )
+ ip_adapter_extension.run_image_proj(dtype=dtype)
+ pos_ip_adapter_extensions.append(ip_adapter_extension)
+
+ ip_adapter_extension = XLabsIPAdapterExtension(
+ model=ip_adapter_model,
+ image_prompt_clip_embed=neg_image_prompt_clip_embed,
+ weight=ip_adapter_field.weight,
+ begin_step_percent=ip_adapter_field.begin_step_percent,
+ end_step_percent=ip_adapter_field.end_step_percent,
+ )
+ ip_adapter_extension.run_image_proj(dtype=dtype)
+ neg_ip_adapter_extensions.append(ip_adapter_extension)
+
+ return pos_ip_adapter_extensions, neg_ip_adapter_extensions
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ loras: list[Union[LoRAField, ControlLoRAField]] = [*self.transformer.loras]
+ if self.control_lora:
+ # Note: Since FLUX structural control LoRAs modify the shape of some weights, it is important that they are
+ # applied last.
+ loras.append(self.control_lora)
+ for lora in loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ yield (lora_info.model, lora.weight)
+ del lora_info
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ # The denoise function now handles Kontext conditioning correctly,
+ # so we don't need to slice the latents here
+ latents = state.latents.float()
+ state.latents = unpack(latents, self.height, self.width).squeeze()
+ context.util.flux_step_callback(state)
+
+ return step_callback
diff --git a/invokeai/app/invocations/flux_fill.py b/invokeai/app/invocations/flux_fill.py
new file mode 100644
index 00000000000..440f3e5c971
--- /dev/null
+++ b/invokeai/app/invocations/flux_fill.py
@@ -0,0 +1,46 @@
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ FluxFillConditioningField,
+ InputField,
+ OutputField,
+ TensorField,
+)
+from invokeai.app.invocations.primitives import ImageField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("flux_fill_output")
+class FluxFillOutput(BaseInvocationOutput):
+ """The conditioning output of a FLUX Fill invocation."""
+
+ fill_cond: FluxFillConditioningField = OutputField(
+ description=FieldDescriptions.flux_redux_conditioning, title="Conditioning"
+ )
+
+
+@invocation(
+ "flux_fill",
+ title="FLUX Fill Conditioning",
+ tags=["inpaint"],
+ category="conditioning",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class FluxFillInvocation(BaseInvocation):
+ """Prepare the FLUX Fill conditioning data."""
+
+ image: ImageField = InputField(description="The FLUX Fill reference image.")
+ mask: TensorField = InputField(
+ description="The bool inpainting mask. Excluded regions should be set to "
+ "False, included regions should be set to True.",
+ )
+
+ def invoke(self, context: InvocationContext) -> FluxFillOutput:
+ return FluxFillOutput(fill_cond=FluxFillConditioningField(image=self.image, mask=self.mask))
diff --git a/invokeai/app/invocations/flux_ip_adapter.py b/invokeai/app/invocations/flux_ip_adapter.py
new file mode 100644
index 00000000000..c0d797d0bdd
--- /dev/null
+++ b/invokeai/app/invocations/flux_ip_adapter.py
@@ -0,0 +1,89 @@
+from builtins import float
+from typing import List, Literal, Union
+
+from pydantic import field_validator, model_validator
+from typing_extensions import Self
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField
+from invokeai.app.invocations.ip_adapter import (
+ CLIP_VISION_MODEL_MAP,
+ IPAdapterField,
+ IPAdapterInvocation,
+ IPAdapterOutput,
+)
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import ImageField
+from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.configs.ip_adapter import IPAdapter_Checkpoint_FLUX_Config
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation(
+ "flux_ip_adapter",
+ title="FLUX IP-Adapter",
+ tags=["ip_adapter", "control"],
+ category="conditioning",
+ version="1.0.0",
+)
+class FluxIPAdapterInvocation(BaseInvocation):
+ """Collects FLUX IP-Adapter info to pass to other nodes."""
+
+ # FLUXIPAdapterInvocation is based closely on IPAdapterInvocation, but with some unsupported features removed.
+
+ image: ImageField = InputField(description="The IP-Adapter image prompt(s).")
+ ip_adapter_model: ModelIdentifierField = InputField(
+ description="The IP-Adapter model.",
+ title="IP-Adapter Model",
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.IPAdapter,
+ )
+ # Currently, the only known ViT model used by FLUX IP-Adapters is ViT-L.
+ clip_vision_model: Literal["ViT-L"] = InputField(description="CLIP Vision model to use.", default="ViT-L")
+ weight: Union[float, List[float]] = InputField(
+ default=1, description="The weight given to the IP-Adapter", title="Weight"
+ )
+ begin_step_percent: float = InputField(
+ default=0, ge=0, le=1, description="When the IP-Adapter is first applied (% of total steps)"
+ )
+ end_step_percent: float = InputField(
+ default=1, ge=0, le=1, description="When the IP-Adapter is last applied (% of total steps)"
+ )
+
+ @field_validator("weight")
+ @classmethod
+ def validate_ip_adapter_weight(cls, v: float) -> float:
+ validate_weights(v)
+ return v
+
+ @model_validator(mode="after")
+ def validate_begin_end_step_percent(self) -> Self:
+ validate_begin_end_step(self.begin_step_percent, self.end_step_percent)
+ return self
+
+ def invoke(self, context: InvocationContext) -> IPAdapterOutput:
+ # Lookup the CLIP Vision encoder that is intended to be used with the IP-Adapter model.
+ ip_adapter_info = context.models.get_config(self.ip_adapter_model.key)
+ assert isinstance(ip_adapter_info, IPAdapter_Checkpoint_FLUX_Config)
+
+ # Note: There is a IPAdapterInvokeAIConfig.image_encoder_model_id field, but it isn't trustworthy.
+ image_encoder_starter_model = CLIP_VISION_MODEL_MAP[self.clip_vision_model]
+ image_encoder_model_id = image_encoder_starter_model.source
+ image_encoder_model_name = image_encoder_starter_model.name
+ image_encoder_model = IPAdapterInvocation.get_clip_image_encoder(
+ context, image_encoder_model_id, image_encoder_model_name
+ )
+
+ return IPAdapterOutput(
+ ip_adapter=IPAdapterField(
+ image=self.image,
+ ip_adapter_model=self.ip_adapter_model,
+ image_encoder_model=ModelIdentifierField.from_config(image_encoder_model),
+ weight=self.weight,
+ target_blocks=[], # target_blocks is currently unused for FLUX IP-Adapters.
+ begin_step_percent=self.begin_step_percent,
+ end_step_percent=self.end_step_percent,
+ mask=None, # mask is currently unused for FLUX IP-Adapters.
+ ),
+ )
diff --git a/invokeai/app/invocations/flux_kontext.py b/invokeai/app/invocations/flux_kontext.py
new file mode 100644
index 00000000000..6820f3b3514
--- /dev/null
+++ b/invokeai/app/invocations/flux_kontext.py
@@ -0,0 +1,40 @@
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ FluxKontextConditioningField,
+ InputField,
+ OutputField,
+)
+from invokeai.app.invocations.primitives import ImageField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("flux_kontext_output")
+class FluxKontextOutput(BaseInvocationOutput):
+ """The conditioning output of a FLUX Kontext invocation."""
+
+ kontext_cond: FluxKontextConditioningField = OutputField(
+ description=FieldDescriptions.flux_kontext_conditioning, title="Kontext Conditioning"
+ )
+
+
+@invocation(
+ "flux_kontext",
+ title="Kontext Conditioning - FLUX",
+ tags=["conditioning", "kontext", "flux"],
+ category="conditioning",
+ version="1.0.0",
+)
+class FluxKontextInvocation(BaseInvocation):
+ """Prepares a reference image for FLUX Kontext conditioning."""
+
+ image: ImageField = InputField(description="The Kontext reference image.")
+
+ def invoke(self, context: InvocationContext) -> FluxKontextOutput:
+ """Packages the provided image into a Kontext conditioning field."""
+ return FluxKontextOutput(kontext_cond=FluxKontextConditioningField(image=self.image))
diff --git a/invokeai/app/invocations/flux_lora_loader.py b/invokeai/app/invocations/flux_lora_loader.py
new file mode 100644
index 00000000000..0fd96e097d5
--- /dev/null
+++ b/invokeai/app/invocations/flux_lora_loader.py
@@ -0,0 +1,178 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import CLIPField, LoRAField, ModelIdentifierField, T5EncoderField, TransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("flux_lora_loader_output")
+class FluxLoRALoaderOutput(BaseInvocationOutput):
+ """FLUX LoRA Loader Output"""
+
+ transformer: Optional[TransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="FLUX Transformer"
+ )
+ clip: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP")
+ t5_encoder: Optional[T5EncoderField] = OutputField(
+ default=None, description=FieldDescriptions.t5_encoder, title="T5 Encoder"
+ )
+
+
+@invocation(
+ "flux_lora_loader",
+ title="Apply LoRA - FLUX",
+ tags=["lora", "model", "flux"],
+ category="model",
+ version="1.2.1",
+)
+class FluxLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA model to a FLUX transformer and/or text encoder."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
+ transformer: TransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="FLUX Transformer",
+ )
+ clip: CLIPField | None = InputField(
+ default=None,
+ title="CLIP",
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ )
+ t5_encoder: T5EncoderField | None = InputField(
+ default=None,
+ title="T5 Encoder",
+ description=FieldDescriptions.t5_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> FluxLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ # Check for existing LoRAs with the same key.
+ if self.transformer and any(lora.lora.key == lora_key for lora in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to transformer.')
+ if self.clip and any(lora.lora.key == lora_key for lora in self.clip.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to CLIP encoder.')
+ if self.t5_encoder and any(lora.lora.key == lora_key for lora in self.t5_encoder.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to T5 encoder.')
+
+ output = FluxLoRALoaderOutput()
+
+ # Attach LoRA layers to the models.
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ output.transformer.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+ if self.clip is not None:
+ output.clip = self.clip.model_copy(deep=True)
+ output.clip.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+ if self.t5_encoder is not None:
+ output.t5_encoder = self.t5_encoder.model_copy(deep=True)
+ output.t5_encoder.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "flux_lora_collection_loader",
+ title="Apply LoRA Collection - FLUX",
+ tags=["lora", "model", "flux"],
+ category="model",
+ version="1.3.1",
+)
+class FLUXLoRACollectionLoader(BaseInvocation):
+ """Applies a collection of LoRAs to a FLUX transformer."""
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+
+ transformer: Optional[TransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ clip: CLIPField | None = InputField(
+ default=None,
+ title="CLIP",
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ )
+ t5_encoder: T5EncoderField | None = InputField(
+ default=None,
+ title="T5 Encoder",
+ description=FieldDescriptions.t5_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> FluxLoRALoaderOutput:
+ output = FluxLoRALoaderOutput()
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added_loras: list[str] = []
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ if self.clip is not None:
+ output.clip = self.clip.model_copy(deep=True)
+
+ if self.t5_encoder is not None:
+ output.t5_encoder = self.t5_encoder.model_copy(deep=True)
+
+ for lora in loras:
+ if lora is None:
+ continue
+ if lora.lora.key in added_loras:
+ continue
+
+ if not context.models.exists(lora.lora.key):
+ raise Exception(f"Unknown lora: {lora.lora.key}!")
+
+ assert lora.lora.base in (BaseModelType.Flux, BaseModelType.Flux2)
+
+ added_loras.append(lora.lora.key)
+
+ if self.transformer is not None and output.transformer is not None:
+ output.transformer.loras.append(lora)
+
+ if self.clip is not None and output.clip is not None:
+ output.clip.loras.append(lora)
+
+ if self.t5_encoder is not None and output.t5_encoder is not None:
+ output.t5_encoder.loras.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/flux_model_loader.py b/invokeai/app/invocations/flux_model_loader.py
new file mode 100644
index 00000000000..c175ae7fedc
--- /dev/null
+++ b/invokeai/app/invocations/flux_model_loader.py
@@ -0,0 +1,93 @@
+from typing import Literal
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField
+from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.t5_model_identifier import (
+ preprocess_t5_encoder_model_identifier,
+ preprocess_t5_tokenizer_model_identifier,
+)
+from invokeai.backend.flux.util import get_flux_max_seq_length
+from invokeai.backend.model_manager.configs.base import Checkpoint_Config_Base
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
+
+
+@invocation_output("flux_model_loader_output")
+class FluxModelLoaderOutput(BaseInvocationOutput):
+ """Flux base model loader output"""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ clip: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP")
+ t5_encoder: T5EncoderField = OutputField(description=FieldDescriptions.t5_encoder, title="T5 Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+ max_seq_len: Literal[256, 512] = OutputField(
+ description="The max sequence length to used for the T5 encoder. (256 for schnell transformer, 512 for dev transformer)",
+ title="Max Seq Length",
+ )
+
+
+@invocation(
+ "flux_model_loader",
+ title="Main Model - FLUX",
+ tags=["model", "flux"],
+ category="model",
+ version="1.0.7",
+)
+class FluxModelLoaderInvocation(BaseInvocation):
+ """Loads a flux base model, outputting its submodels."""
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.flux_model,
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.Main,
+ )
+
+ t5_encoder_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.t5_encoder,
+ title="T5 Encoder",
+ ui_model_type=ModelType.T5Encoder,
+ )
+
+ clip_embed_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.clip_embed_model,
+ title="CLIP Embed",
+ ui_model_type=ModelType.CLIPEmbed,
+ )
+
+ vae_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.vae_model,
+ title="VAE",
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.VAE,
+ )
+
+ def invoke(self, context: InvocationContext) -> FluxModelLoaderOutput:
+ for key in [self.model.key, self.t5_encoder_model.key, self.clip_embed_model.key, self.vae_model.key]:
+ if not context.models.exists(key):
+ raise ValueError(f"Unknown model: {key}")
+
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+
+ tokenizer = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ clip_encoder = self.clip_embed_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+
+ tokenizer2 = preprocess_t5_tokenizer_model_identifier(self.t5_encoder_model)
+ t5_encoder = preprocess_t5_encoder_model_identifier(self.t5_encoder_model)
+
+ transformer_config = context.models.get_config(transformer)
+ assert isinstance(transformer_config, Checkpoint_Config_Base)
+
+ return FluxModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ clip=CLIPField(tokenizer=tokenizer, text_encoder=clip_encoder, loras=[], skipped_layers=0),
+ t5_encoder=T5EncoderField(tokenizer=tokenizer2, text_encoder=t5_encoder, loras=[]),
+ vae=VAEField(vae=vae),
+ max_seq_len=get_flux_max_seq_length(transformer_config.variant),
+ )
diff --git a/invokeai/app/invocations/flux_redux.py b/invokeai/app/invocations/flux_redux.py
new file mode 100644
index 00000000000..ac1f5764d78
--- /dev/null
+++ b/invokeai/app/invocations/flux_redux.py
@@ -0,0 +1,167 @@
+import math
+from typing import Literal, Optional
+
+import torch
+from PIL import Image
+from transformers import SiglipImageProcessor, SiglipVisionModel
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ FluxReduxConditioningField,
+ InputField,
+ OutputField,
+ TensorField,
+)
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import ImageField
+from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.redux.flux_redux_model import FluxReduxModel
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.starter_models import siglip
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+from invokeai.backend.sig_lip.sig_lip_pipeline import SigLipPipeline
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation_output("flux_redux_output")
+class FluxReduxOutput(BaseInvocationOutput):
+ """The conditioning output of a FLUX Redux invocation."""
+
+ redux_cond: FluxReduxConditioningField = OutputField(
+ description=FieldDescriptions.flux_redux_conditioning, title="Conditioning"
+ )
+
+
+DOWNSAMPLING_FUNCTIONS = Literal["nearest", "bilinear", "bicubic", "area", "nearest-exact"]
+
+
+@invocation(
+ "flux_redux",
+ title="FLUX Redux",
+ tags=["ip_adapter", "control"],
+ category="conditioning",
+ version="2.1.0",
+ classification=Classification.Beta,
+ idle_gpu_offloadable=True,
+)
+class FluxReduxInvocation(BaseInvocation):
+ """Runs a FLUX Redux model to generate a conditioning tensor."""
+
+ image: ImageField = InputField(description="The FLUX Redux image prompt.")
+ mask: Optional[TensorField] = InputField(
+ default=None,
+ description="The bool mask associated with this FLUX Redux image prompt. Excluded regions should be set to "
+ "False, included regions should be set to True.",
+ )
+ redux_model: ModelIdentifierField = InputField(
+ description="The FLUX Redux model to use.",
+ title="FLUX Redux Model",
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.FluxRedux,
+ )
+ downsampling_factor: int = InputField(
+ ge=1,
+ le=9,
+ default=1,
+ description="Redux Downsampling Factor (1-9)",
+ )
+ downsampling_function: DOWNSAMPLING_FUNCTIONS = InputField(
+ default="area",
+ description="Redux Downsampling Function",
+ )
+ weight: float = InputField(
+ ge=0,
+ le=1,
+ default=1.0,
+ description="Redux weight (0.0-1.0)",
+ )
+
+ def invoke(self, context: InvocationContext) -> FluxReduxOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+
+ encoded_x = self._siglip_encode(context, image)
+ redux_conditioning = self._flux_redux_encode(context, encoded_x)
+ if self.downsampling_factor > 1 or self.weight != 1.0:
+ redux_conditioning = self._downsample_weight(context, redux_conditioning)
+
+ tensor_name = context.tensors.save(redux_conditioning)
+ return FluxReduxOutput(
+ redux_cond=FluxReduxConditioningField(conditioning=TensorField(tensor_name=tensor_name), mask=self.mask)
+ )
+
+ @torch.no_grad()
+ def _downsample_weight(self, context: InvocationContext, redux_conditioning: torch.Tensor) -> torch.Tensor:
+ # Downsampling derived from https://github.com/kaibioinfo/ComfyUI_AdvancedRefluxControl
+ (b, t, h) = redux_conditioning.shape
+ m = int(math.sqrt(t))
+ if self.downsampling_factor > 1:
+ redux_conditioning = redux_conditioning.view(b, m, m, h)
+ redux_conditioning = torch.nn.functional.interpolate(
+ redux_conditioning.transpose(1, -1),
+ size=(m // self.downsampling_factor, m // self.downsampling_factor),
+ mode=self.downsampling_function,
+ )
+ redux_conditioning = redux_conditioning.transpose(1, -1).reshape(b, -1, h)
+ if self.weight != 1.0:
+ redux_conditioning = redux_conditioning * self.weight * self.weight
+ return redux_conditioning
+
+ @torch.no_grad()
+ def _siglip_encode(self, context: InvocationContext, image: Image.Image) -> torch.Tensor:
+ siglip_model_config = self._get_siglip_model(context)
+ with context.models.load(siglip_model_config.key).model_on_device() as (_, model):
+ assert isinstance(model, SiglipVisionModel)
+
+ model_abs_path = context.models.get_absolute_path(siglip_model_config)
+ processor = SiglipImageProcessor.from_pretrained(model_abs_path, local_files_only=True)
+ assert isinstance(processor, SiglipImageProcessor)
+
+ siglip_pipeline = SigLipPipeline(processor, model)
+ return siglip_pipeline.encode_image(
+ x=image, device=TorchDevice.choose_torch_device(), dtype=TorchDevice.choose_torch_dtype()
+ )
+
+ @torch.no_grad()
+ def _flux_redux_encode(self, context: InvocationContext, encoded_x: torch.Tensor) -> torch.Tensor:
+ with context.models.load(self.redux_model).model_on_device() as (_, flux_redux):
+ assert isinstance(flux_redux, FluxReduxModel)
+ dtype = next(flux_redux.parameters()).dtype
+ encoded_x = encoded_x.to(dtype=dtype)
+ return flux_redux(encoded_x)
+
+ def _get_siglip_model(self, context: InvocationContext) -> AnyModelConfig:
+ siglip_models = context.models.search_by_attrs(name=siglip.name, base=BaseModelType.Any, type=ModelType.SigLIP)
+
+ if not len(siglip_models) > 0:
+ context.logger.warning(
+ f"The SigLIP model required by FLUX Redux ({siglip.name}) is not installed. Downloading and installing now. This may take a while."
+ )
+
+ # TODO(psyche): Can the probe reliably determine the type of the model? Just hardcoding it bc I don't want to experiment now
+ config_overrides = ModelRecordChanges(name=siglip.name, type=ModelType.SigLIP)
+
+ # Queue the job
+ job = context._services.model_manager.install.heuristic_import(siglip.source, config=config_overrides)
+
+ # Wait for up to 10 minutes - model is ~3.5GB
+ context._services.model_manager.install.wait_for_job(job, timeout=600)
+
+ siglip_models = context.models.search_by_attrs(
+ name=siglip.name,
+ base=BaseModelType.Any,
+ type=ModelType.SigLIP,
+ )
+
+ if len(siglip_models) == 0:
+ context.logger.error("Error while fetching SigLIP for FLUX Redux")
+ assert len(siglip_models) == 1
+
+ return siglip_models[0]
diff --git a/invokeai/app/invocations/flux_text_encoder.py b/invokeai/app/invocations/flux_text_encoder.py
new file mode 100644
index 00000000000..e3f28e57d72
--- /dev/null
+++ b/invokeai/app/invocations/flux_text_encoder.py
@@ -0,0 +1,270 @@
+from contextlib import ExitStack
+from typing import Iterator, Literal, Optional, Tuple, Union
+
+import torch
+from transformers import CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer, T5TokenizerFast
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ FluxConditioningField,
+ Input,
+ InputField,
+ TensorField,
+ UIComponent,
+)
+from invokeai.app.invocations.model import CLIPField, T5EncoderField
+from invokeai.app.invocations.primitives import FluxConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.conditioner import HFEncoder
+from invokeai.backend.model_manager.taxonomy import ModelFormat
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_CLIP_PREFIX, FLUX_LORA_T5_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, FLUXConditioningInfo
+
+
+@invocation(
+ "flux_text_encoder",
+ title="Prompt - FLUX",
+ tags=["prompt", "conditioning", "flux"],
+ category="prompt",
+ version="1.1.2",
+ idle_gpu_offloadable=True,
+)
+class FluxTextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for a flux image."""
+
+ clip: CLIPField = InputField(
+ title="CLIP",
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ )
+ t5_encoder: T5EncoderField = InputField(
+ title="T5Encoder",
+ description=FieldDescriptions.t5_encoder,
+ input=Input.Connection,
+ )
+ t5_max_seq_len: Literal[256, 512] = InputField(
+ description="Max sequence length for the T5 encoder. Expected to be 256 for FLUX schnell models and 512 for FLUX dev models."
+ )
+ prompt: str = InputField(description="Text prompt to encode.", ui_component=UIComponent.Textarea)
+ mask: Optional[TensorField] = InputField(
+ default=None, description="A mask defining the region that this conditioning prompt applies to."
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> FluxConditioningOutput:
+ # Note: The T5 and CLIP encoding are done in separate functions to ensure that all model references are locally
+ # scoped. This ensures that the T5 model can be freed and gc'd before loading the CLIP model (if necessary).
+ t5_embeddings = self._t5_encode(context)
+ clip_embeddings = self._clip_encode(context)
+
+ # Move embeddings to CPU for storage to save VRAM
+ # They will be moved to the appropriate device when used by the denoiser
+ t5_embeddings = t5_embeddings.detach().to("cpu")
+ clip_embeddings = clip_embeddings.detach().to("cpu")
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[FLUXConditioningInfo(clip_embeds=clip_embeddings, t5_embeds=t5_embeddings)]
+ )
+
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return FluxConditioningOutput(
+ conditioning=FluxConditioningField(conditioning_name=conditioning_name, mask=self.mask)
+ )
+
+ def _t5_encode(self, context: InvocationContext) -> torch.Tensor:
+ prompt = [self.prompt]
+
+ t5_encoder_info = context.models.load(self.t5_encoder.text_encoder)
+ t5_encoder_config = t5_encoder_info.config
+ assert t5_encoder_config is not None
+
+ with (
+ t5_encoder_info.model_on_device() as (cached_weights, t5_text_encoder),
+ context.models.load(self.t5_encoder.tokenizer) as t5_tokenizer,
+ ExitStack() as exit_stack,
+ ):
+ assert isinstance(t5_text_encoder, T5EncoderModel)
+ assert isinstance(t5_tokenizer, (T5Tokenizer, T5TokenizerFast))
+
+ # Determine if the model is quantized.
+ # If the model is quantized, then we need to apply the LoRA weights as sidecar layers. This results in
+ # slower inference than direct patching, but is agnostic to the quantization format.
+ if t5_encoder_config.format in [ModelFormat.T5Encoder, ModelFormat.Diffusers]:
+ model_is_quantized = False
+ elif t5_encoder_config.format in [
+ ModelFormat.BnbQuantizedLlmInt8b,
+ ModelFormat.BnbQuantizednf4b,
+ ModelFormat.GGUFQuantized,
+ ]:
+ model_is_quantized = True
+ else:
+ raise ValueError(f"Unsupported model format: {t5_encoder_config.format}")
+
+ # Apply LoRA models to the T5 encoder.
+ # Note: We apply the LoRA after the encoder has been moved to its target device for faster patching.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=t5_text_encoder,
+ patches=self._t5_lora_iterator(context),
+ prefix=FLUX_LORA_T5_PREFIX,
+ dtype=t5_text_encoder.dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=model_is_quantized,
+ )
+ )
+
+ t5_encoder = HFEncoder(t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len)
+
+ if context.config.get().log_tokenization:
+ self._log_t5_tokenization(context, t5_tokenizer)
+
+ context.util.signal_progress("Running T5 encoder")
+ prompt_embeds = t5_encoder(prompt)
+
+ assert isinstance(prompt_embeds, torch.Tensor)
+ return prompt_embeds
+
+ def _clip_encode(self, context: InvocationContext) -> torch.Tensor:
+ prompt = [self.prompt]
+
+ clip_text_encoder_info = context.models.load(self.clip.text_encoder)
+ clip_text_encoder_config = clip_text_encoder_info.config
+ assert clip_text_encoder_config is not None
+
+ with (
+ clip_text_encoder_info.model_on_device() as (cached_weights, clip_text_encoder),
+ context.models.load(self.clip.tokenizer) as clip_tokenizer,
+ ExitStack() as exit_stack,
+ ):
+ assert isinstance(clip_text_encoder, CLIPTextModel)
+ assert isinstance(clip_tokenizer, CLIPTokenizer)
+
+ # Apply LoRA models to the CLIP encoder.
+ # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching.
+ if clip_text_encoder_config.format in [ModelFormat.Diffusers]:
+ # The model is non-quantized, so we can apply the LoRA weights directly into the model.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=clip_text_encoder,
+ patches=self._clip_lora_iterator(context),
+ prefix=FLUX_LORA_CLIP_PREFIX,
+ dtype=clip_text_encoder.dtype,
+ cached_weights=cached_weights,
+ )
+ )
+ else:
+ # There are currently no supported CLIP quantized models. Add support here if needed.
+ raise ValueError(f"Unsupported model format: {clip_text_encoder_config.format}")
+
+ clip_encoder = HFEncoder(clip_text_encoder, clip_tokenizer, True, 77)
+
+ if context.config.get().log_tokenization:
+ self._log_clip_tokenization(context, clip_tokenizer)
+
+ context.util.signal_progress("Running CLIP encoder")
+ pooled_prompt_embeds = clip_encoder(prompt)
+
+ assert isinstance(pooled_prompt_embeds, torch.Tensor)
+ return pooled_prompt_embeds
+
+ def _clip_lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ for lora in self.clip.loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ yield (lora_info.model, lora.weight)
+ del lora_info
+
+ def _t5_lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ for lora in self.t5_encoder.loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ yield (lora_info.model, lora.weight)
+ del lora_info
+
+ def _log_t5_tokenization(
+ self,
+ context: InvocationContext,
+ tokenizer: Union[T5Tokenizer, T5TokenizerFast],
+ ) -> None:
+ """Logs the tokenization of a prompt for a T5-based model like FLUX."""
+
+ # Tokenize the prompt using the same parameters as the model's text encoder.
+ # T5 tokenizers add an EOS token () and then pad to max_length.
+ tokenized_output = tokenizer(
+ self.prompt,
+ padding="max_length",
+ max_length=self.t5_max_seq_len,
+ truncation=True,
+ add_special_tokens=True, # This is important for T5 to add the EOS token.
+ return_tensors="pt",
+ )
+
+ input_ids = tokenized_output.input_ids[0]
+ tokens = tokenizer.convert_ids_to_tokens(input_ids)
+
+ # The T5 tokenizer uses a space-like character ' ' (U+2581) to denote spaces.
+ # We'll replace it with a regular space for readability.
+ tokens = [t.replace("\u2581", " ") for t in tokens]
+
+ tokenized_str = ""
+ used_tokens = 0
+ for token in tokens:
+ if token == tokenizer.eos_token:
+ tokenized_str += f"\x1b[0;31m{token}\x1b[0m" # Red for EOS
+ used_tokens += 1
+ elif token == tokenizer.pad_token:
+ # tokenized_str += f"\x1b[0;34m{token}\x1b[0m" # Blue for PAD
+ continue
+ else:
+ color = (used_tokens % 6) + 1 # Cycle through 6 colors
+ tokenized_str += f"\x1b[0;3{color}m{token}\x1b[0m"
+ used_tokens += 1
+
+ context.logger.info(f">> [T5 TOKENLOG] Tokens ({used_tokens}/{self.t5_max_seq_len}):")
+ context.logger.info(f"{tokenized_str}\x1b[0m")
+
+ def _log_clip_tokenization(
+ self,
+ context: InvocationContext,
+ tokenizer: CLIPTokenizer,
+ ) -> None:
+ """Logs the tokenization of a prompt for a CLIP-based model."""
+ max_length = tokenizer.model_max_length
+
+ tokenized_output = tokenizer(
+ self.prompt,
+ padding="max_length",
+ max_length=max_length,
+ truncation=True,
+ return_tensors="pt",
+ )
+
+ input_ids = tokenized_output.input_ids[0]
+ attention_mask = tokenized_output.attention_mask[0]
+ tokens = tokenizer.convert_ids_to_tokens(input_ids)
+
+ # The CLIP tokenizer uses '' to denote spaces.
+ # We'll replace it with a regular space for readability.
+ tokens = [t.replace("", " ") for t in tokens]
+
+ tokenized_str = ""
+ used_tokens = 0
+ for i, token in enumerate(tokens):
+ if attention_mask[i] == 0:
+ # Do not log padding tokens.
+ continue
+
+ if token == tokenizer.bos_token:
+ tokenized_str += f"\x1b[0;32m{token}\x1b[0m" # Green for BOS
+ elif token == tokenizer.eos_token:
+ tokenized_str += f"\x1b[0;31m{token}\x1b[0m" # Red for EOS
+ else:
+ color = (used_tokens % 6) + 1 # Cycle through 6 colors
+ tokenized_str += f"\x1b[0;3{color}m{token}\x1b[0m"
+ used_tokens += 1
+
+ context.logger.info(f">> [CLIP TOKENLOG] Tokens ({used_tokens}/{max_length}):")
+ context.logger.info(f"{tokenized_str}\x1b[0m")
diff --git a/invokeai/app/invocations/flux_vae_decode.py b/invokeai/app/invocations/flux_vae_decode.py
new file mode 100644
index 00000000000..c55dfb539ac
--- /dev/null
+++ b/invokeai/app/invocations/flux_vae_decode.py
@@ -0,0 +1,67 @@
+import torch
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+
+@invocation(
+ "flux_vae_decode",
+ title="Latents to Image - FLUX",
+ tags=["latents", "image", "vae", "l2i", "flux"],
+ category="latents",
+ version="1.0.2",
+)
+class FluxVaeDecodeInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents."""
+
+ latents: LatentsField = InputField(
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ vae: VAEField = InputField(
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+
+ def _vae_decode(self, vae_info: LoadedModel, latents: torch.Tensor) -> Image.Image:
+ assert isinstance(vae_info.model, AutoEncoder)
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="decode", image_tensor=latents, vae=vae_info.model
+ )
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoEncoder)
+ vae_dtype = next(iter(vae.parameters())).dtype
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+ img = vae.decode(latents)
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c") # noqa: F821
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+ return img_pil
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+ vae_info = context.models.load(self.vae.vae)
+ context.util.signal_progress("Running VAE")
+ image = self._vae_decode(vae_info=vae_info, latents=latents)
+
+ TorchDevice.empty_cache()
+ image_dto = context.images.save(image=image)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/flux_vae_encode.py b/invokeai/app/invocations/flux_vae_encode.py
new file mode 100644
index 00000000000..4ec0365c2cb
--- /dev/null
+++ b/invokeai/app/invocations/flux_vae_encode.py
@@ -0,0 +1,72 @@
+import einops
+import torch
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+
+@invocation(
+ "flux_vae_encode",
+ title="Image to Latents - FLUX",
+ tags=["latents", "image", "vae", "i2l", "flux"],
+ category="latents",
+ version="1.0.1",
+)
+class FluxVaeEncodeInvocation(BaseInvocation):
+ """Encodes an image into latents."""
+
+ image: ImageField = InputField(
+ description="The image to encode.",
+ )
+ vae: VAEField = InputField(
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ # TODO(ryand): Expose seed parameter at the invocation level.
+ # TODO(ryand): Write a util function for generating random tensors that is consistent across devices / dtypes.
+ # There's a starting point in get_noise(...), but it needs to be extracted and generalized. This function
+ # should be used for VAE encode sampling.
+ assert isinstance(vae_info.model, AutoEncoder)
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="encode", image_tensor=image_tensor, vae=vae_info.model
+ )
+ generator = torch.Generator(device=TorchDevice.choose_torch_device()).manual_seed(0)
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoEncoder)
+ vae_dtype = next(iter(vae.parameters())).dtype
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+ latents = vae.encode(image_tensor, sample=True, generator=generator)
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ vae_info = context.models.load(self.vae.vae)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ context.util.signal_progress("Running VAE")
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/grounding_dino.py b/invokeai/app/invocations/grounding_dino.py
new file mode 100644
index 00000000000..4d900c5034c
--- /dev/null
+++ b/invokeai/app/invocations/grounding_dino.py
@@ -0,0 +1,100 @@
+from pathlib import Path
+from typing import Literal
+
+import torch
+from PIL import Image
+from transformers import pipeline
+from transformers.pipelines import ZeroShotObjectDetectionPipeline
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import BoundingBoxField, ImageField, InputField
+from invokeai.app.invocations.primitives import BoundingBoxCollectionOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.grounding_dino.detection_result import DetectionResult
+from invokeai.backend.image_util.grounding_dino.grounding_dino_pipeline import GroundingDinoPipeline
+
+GroundingDinoModelKey = Literal["grounding-dino-tiny", "grounding-dino-base"]
+GROUNDING_DINO_MODEL_IDS: dict[GroundingDinoModelKey, str] = {
+ "grounding-dino-tiny": "IDEA-Research/grounding-dino-tiny",
+ "grounding-dino-base": "IDEA-Research/grounding-dino-base",
+}
+
+
+@invocation(
+ "grounding_dino",
+ title="Grounding DINO (Text Prompt Object Detection)",
+ tags=["prompt", "object detection"],
+ category="segmentation",
+ version="1.0.0",
+)
+class GroundingDinoInvocation(BaseInvocation):
+ """Runs a Grounding DINO model. Performs zero-shot bounding-box object detection from a text prompt."""
+
+ # Reference:
+ # - https://arxiv.org/pdf/2303.05499
+ # - https://huggingface.co/docs/transformers/v4.43.3/en/model_doc/grounding-dino#grounded-sam
+ # - https://github.com/NielsRogge/Transformers-Tutorials/blob/a39f33ac1557b02ebfb191ea7753e332b5ca933f/Grounding%20DINO/GroundingDINO_with_Segment_Anything.ipynb
+
+ model: GroundingDinoModelKey = InputField(description="The Grounding DINO model to use.")
+ prompt: str = InputField(description="The prompt describing the object to segment.")
+ image: ImageField = InputField(description="The image to segment.")
+ detection_threshold: float = InputField(
+ description="The detection threshold for the Grounding DINO model. All detected bounding boxes with scores above this threshold will be returned.",
+ ge=0.0,
+ le=1.0,
+ default=0.3,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> BoundingBoxCollectionOutput:
+ # The model expects a 3-channel RGB image.
+ image_pil = context.images.get_pil(self.image.image_name, mode="RGB")
+
+ detections = self._detect(
+ context=context, image=image_pil, labels=[self.prompt], threshold=self.detection_threshold
+ )
+
+ # Convert detections to BoundingBoxCollectionOutput.
+ bounding_boxes: list[BoundingBoxField] = []
+ for detection in detections:
+ bounding_boxes.append(
+ BoundingBoxField(
+ x_min=detection.box.xmin,
+ x_max=detection.box.xmax,
+ y_min=detection.box.ymin,
+ y_max=detection.box.ymax,
+ score=detection.score,
+ )
+ )
+ return BoundingBoxCollectionOutput(collection=bounding_boxes)
+
+ @staticmethod
+ def _load_grounding_dino(model_path: Path):
+ grounding_dino_pipeline = pipeline(
+ model=str(model_path),
+ task="zero-shot-object-detection",
+ local_files_only=True,
+ # TODO(ryand): Setting the torch_dtype here doesn't work. Investigate whether fp16 is supported by the
+ # model, and figure out how to make it work in the pipeline.
+ # torch_dtype=TorchDevice.choose_torch_dtype(),
+ )
+ assert isinstance(grounding_dino_pipeline, ZeroShotObjectDetectionPipeline)
+ return GroundingDinoPipeline(grounding_dino_pipeline)
+
+ def _detect(
+ self,
+ context: InvocationContext,
+ image: Image.Image,
+ labels: list[str],
+ threshold: float = 0.3,
+ ) -> list[DetectionResult]:
+ """Use Grounding DINO to detect bounding boxes for a set of labels in an image."""
+ # TODO(ryand): I copied this "."-handling logic from the transformers example code. Test it and see if it
+ # actually makes a difference.
+ labels = [label if label.endswith(".") else label + "." for label in labels]
+
+ with context.models.load_remote_model(
+ source=GROUNDING_DINO_MODEL_IDS[self.model], loader=GroundingDinoInvocation._load_grounding_dino
+ ) as detector:
+ assert isinstance(detector, GroundingDinoPipeline)
+ return detector.detect(image=image, candidate_labels=labels, threshold=threshold)
diff --git a/invokeai/app/invocations/hed.py b/invokeai/app/invocations/hed.py
new file mode 100644
index 00000000000..e2b68143e52
--- /dev/null
+++ b/invokeai/app/invocations/hed.py
@@ -0,0 +1,33 @@
+from builtins import bool
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.hed import ControlNetHED_Apache2, HEDEdgeDetector
+
+
+@invocation(
+ "hed_edge_detection",
+ title="HED Edge Detection",
+ tags=["controlnet", "hed", "softedge"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class HEDEdgeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Geneartes an edge map using the HED (softedge) model."""
+
+ image: ImageField = InputField(description="The image to process")
+ scribble: bool = InputField(default=False, description=FieldDescriptions.scribble_mode)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ loaded_model = context.models.load_remote_model(HEDEdgeDetector.get_model_url(), HEDEdgeDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, ControlNetHED_Apache2)
+ hed_processor = HEDEdgeDetector(model)
+ edge_map = hed_processor.run(image=image, scribble=self.scribble)
+
+ image_dto = context.images.save(image=edge_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/ideal_size.py b/invokeai/app/invocations/ideal_size.py
index 120f8c1ba01..5cfa9c04d01 100644
--- a/invokeai/app/invocations/ideal_size.py
+++ b/invokeai/app/invocations/ideal_size.py
@@ -6,7 +6,7 @@
from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField
from invokeai.app.invocations.model import UNetField
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager.config import BaseModelType
+from invokeai.backend.model_manager.taxonomy import BaseModelType
@invocation_output("ideal_size_output")
@@ -19,16 +19,17 @@ class IdealSizeOutput(BaseInvocationOutput):
@invocation(
"ideal_size",
- title="Ideal Size",
+ title="Ideal Size - SD1.5, SDXL",
tags=["latents", "math", "ideal_size"],
- version="1.0.3",
+ category="latents",
+ version="1.0.6",
)
class IdealSizeInvocation(BaseInvocation):
"""Calculates the ideal size for generation to avoid duplication"""
width: int = InputField(default=1024, description="Final image width")
height: int = InputField(default=576, description="Final image height")
- unet: UNetField = InputField(default=None, description=FieldDescriptions.unet)
+ unet: UNetField = InputField(description=FieldDescriptions.unet)
multiplier: float = InputField(
default=1.0,
description="Amount to multiply the model's dimensions by when calculating the ideal size (may result in "
@@ -41,11 +42,21 @@ def trim_to_multiple_of(self, *args: int, multiple_of: int = LATENT_SCALE_FACTOR
def invoke(self, context: InvocationContext) -> IdealSizeOutput:
unet_config = context.models.get_config(self.unet.unet.key)
aspect = self.width / self.height
- dimension: float = 512
- if unet_config.base == BaseModelType.StableDiffusion2:
+
+ if unet_config.base == BaseModelType.StableDiffusion1:
+ dimension = 512
+ elif unet_config.base == BaseModelType.StableDiffusion2:
dimension = 768
- elif unet_config.base == BaseModelType.StableDiffusionXL:
+ elif unet_config.base in (
+ BaseModelType.StableDiffusionXL,
+ BaseModelType.Flux,
+ BaseModelType.Flux2,
+ BaseModelType.StableDiffusion3,
+ ):
dimension = 1024
+ else:
+ raise ValueError(f"Unsupported model type: {unet_config.base}")
+
dimension = dimension * self.multiplier
min_dimension = math.floor(dimension * 0.5)
model_area = dimension * dimension # hardcoded for now since all models are trained on square images
diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py
index 65e7ce5e067..18709a25091 100644
--- a/invokeai/app/invocations/image.py
+++ b/invokeai/app/invocations/image.py
@@ -1,13 +1,21 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
+from pathlib import Path
from typing import Literal, Optional
import cv2
import numpy
+import torch
from PIL import Image, ImageChops, ImageFilter, ImageOps
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ Classification,
+ invocation,
+)
from invokeai.app.invocations.constants import IMAGE_MODES
from invokeai.app.invocations.fields import (
+ BoundingBoxField,
ColorField,
FieldDescriptions,
ImageField,
@@ -15,13 +23,43 @@
WithBoard,
WithMetadata,
)
-from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.invocations.primitives import ImageOutput, StringOutput
from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.misc import SEED_MAX
+from invokeai.backend.image_util.color_conversion import (
+ linear_srgb_from_oklab,
+ linear_srgb_from_oklch,
+ linear_srgb_from_srgb,
+ oklab_from_linear_srgb,
+ oklch_from_oklab,
+ srgb_from_linear_srgb,
+)
from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark
from invokeai.backend.image_util.safety_checker import SafetyChecker
-from .baseinvocation import BaseInvocation, Classification, invocation
+
+def _extract_alpha_channel(image: Image.Image) -> Image.Image | None:
+ if image.mode in ("RGBA", "LA", "PA"):
+ return image.getchannel("A")
+ return None
+
+
+def _restore_original_mode(image: Image.Image, mode: str, alpha_channel: Image.Image | None) -> Image.Image:
+ if alpha_channel is None:
+ return image.convert(mode)
+
+ if mode == "RGBA":
+ image = image.convert("RGB")
+ elif mode == "LA":
+ image = image.convert("L")
+ elif mode == "PA":
+ image = image.convert("P")
+ else:
+ return image.convert(mode)
+
+ image.putalpha(alpha_channel)
+ return image
@invocation("show_image", title="Show Image", tags=["image"], category="image", version="1.0.1")
@@ -158,12 +196,12 @@ class ImagePasteInvocation(BaseInvocation, WithMetadata, WithBoard):
crop: bool = InputField(default=False, description="Crop to base image dimensions")
def invoke(self, context: InvocationContext) -> ImageOutput:
- base_image = context.images.get_pil(self.base_image.image_name)
- image = context.images.get_pil(self.image.image_name)
+ base_image = context.images.get_pil(self.base_image.image_name, mode="RGBA")
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
mask = None
if self.mask is not None:
- mask = context.images.get_pil(self.mask.image_name)
- mask = ImageOps.invert(mask.convert("L"))
+ mask = context.images.get_pil(self.mask.image_name, mode="L")
+ mask = ImageOps.invert(mask)
# TODO: probably shouldn't invert mask here... should user be required to do it?
min_x = min(0, self.x)
@@ -173,7 +211,11 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
new_image = Image.new(mode="RGBA", size=(max_x - min_x, max_y - min_y), color=(0, 0, 0, 0))
new_image.paste(base_image, (abs(min_x), abs(min_y)))
- new_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask)
+
+ # Create a temporary image to paste the image with transparency
+ temp_image = Image.new("RGBA", new_image.size)
+ temp_image.paste(image, (max(0, self.x), max(0, self.y)), mask=mask)
+ new_image = Image.alpha_composite(new_image, temp_image)
if self.crop:
base_w, base_h = base_image.size
@@ -188,7 +230,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
"tomask",
title="Mask from Alpha",
tags=["image", "mask"],
- category="image",
+ category="mask",
version="1.2.2",
)
class MaskFromAlphaInvocation(BaseInvocation, WithMetadata, WithBoard):
@@ -298,14 +340,44 @@ class ImageBlurInvocation(BaseInvocation, WithMetadata, WithBoard):
blur_type: Literal["gaussian", "box"] = InputField(default="gaussian", description="The type of blur")
def invoke(self, context: InvocationContext) -> ImageOutput:
- image = context.images.get_pil(self.image.image_name)
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
+
+ # Split the image into RGBA channels
+ r, g, b, a = image.split()
+ # Premultiply RGB channels by alpha
+ premultiplied_image = ImageChops.multiply(image, a.convert("RGBA"))
+ premultiplied_image.putalpha(a)
+
+ # Apply the blur
blur = (
ImageFilter.GaussianBlur(self.radius) if self.blur_type == "gaussian" else ImageFilter.BoxBlur(self.radius)
)
- blur_image = image.filter(blur)
+ blurred_image = premultiplied_image.filter(blur)
+
+ # Split the blurred image into RGBA channels
+ r, g, b, a_orig = blurred_image.split()
+
+ # Convert to float using NumPy. float 32/64 division are much faster than float 16
+ r = numpy.array(r, dtype=numpy.float32)
+ g = numpy.array(g, dtype=numpy.float32)
+ b = numpy.array(b, dtype=numpy.float32)
+ a = numpy.array(a_orig, dtype=numpy.float32) / 255.0 # Normalize alpha to [0, 1]
+
+ # Unpremultiply RGB channels by alpha
+ r /= a + 1e-6 # Add a small epsilon to avoid division by zero
+ g /= a + 1e-6
+ b /= a + 1e-6
- image_dto = context.images.save(image=blur_image)
+ # Convert back to PIL images
+ r = Image.fromarray(numpy.uint8(numpy.clip(r, 0, 255)))
+ g = Image.fromarray(numpy.uint8(numpy.clip(g, 0, 255)))
+ b = Image.fromarray(numpy.uint8(numpy.clip(b, 0, 255)))
+
+ # Merge back into a single image
+ result_image = Image.merge("RGBA", (r, g, b, a_orig))
+
+ image_dto = context.images.save(image=result_image)
return ImageOutput.build(image_dto)
@@ -316,7 +388,6 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
tags=["image", "unsharp_mask"],
category="image",
version="1.2.2",
- classification=Classification.Beta,
)
class UnsharpMaskInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Applies an unsharp mask filter to an image"""
@@ -335,7 +406,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
mode = image.mode
- alpha_channel = image.getchannel("A") if mode == "RGBA" else None
+ alpha_channel = _extract_alpha_channel(image)
image = image.convert("RGB")
image_blurred = self.array_from_pil(image.filter(ImageFilter.GaussianBlur(radius=self.radius)))
@@ -359,6 +430,53 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
)
+@invocation(
+ "unsharp_mask_oklab",
+ title="Unsharp Mask (Oklab)",
+ tags=["image", "unsharp_mask", "oklab"],
+ category="image",
+ version="1.0.0",
+)
+class OklabUnsharpMaskInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Applies an unsharp mask filter to an image in the Oklab color space"""
+
+ image: ImageField = InputField(description="The image to use")
+ radius: float = InputField(gt=0, description="Unsharp mask radius", default=2)
+ strength: float = InputField(ge=0, description="Unsharp mask strength", default=50)
+
+ def pil_from_tensor(self, tensor: torch.Tensor) -> Image.Image:
+ array = torch.clamp(tensor, 0.0, 1.0).permute(1, 2, 0).cpu().numpy()
+ return Image.fromarray((array * 255).astype("uint8"))
+
+ def tensor_from_pil(self, img: Image.Image) -> torch.Tensor:
+ return torch.from_numpy(numpy.array(img, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name)
+ mode = image.mode
+
+ alpha_channel = _extract_alpha_channel(image)
+ image = image.convert("RGB")
+
+ image_blurred = self.tensor_from_pil(image.filter(ImageFilter.GaussianBlur(radius=self.radius)))
+ image_tensor = self.tensor_from_pil(image)
+
+ image_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(image_tensor))
+ image_blurred_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(image_blurred))
+
+ image_oklab[0, ...] += (image_oklab[0, ...] - image_blurred_oklab[0, ...]) * (self.strength / 100.0)
+ image_oklab = torch.clamp(image_oklab, -1.0, 1.0)
+
+ image = _restore_original_mode(
+ self.pil_from_tensor(srgb_from_linear_srgb(linear_srgb_from_oklab(image_oklab))),
+ mode,
+ alpha_channel,
+ )
+
+ image_dto = context.images.save(image=image)
+ return ImageOutput.build(image_dto)
+
+
PIL_RESAMPLING_MODES = Literal[
"nearest",
"box",
@@ -543,11 +661,30 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
return ImageOutput.build(image_dto)
+@invocation(
+ "decode_watermark",
+ title="Decode Invisible Watermark",
+ tags=["image", "watermark"],
+ category="image",
+ version="1.0.0",
+)
+class DecodeInvisibleWatermarkInvocation(BaseInvocation):
+ """Decode an invisible watermark from an image."""
+
+ image: ImageField = InputField(description="The image to decode the watermark from")
+ length: int = InputField(default=8, description="The expected watermark length in bytes")
+
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ image = context.images.get_pil(self.image.image_name)
+ watermark = InvisibleWatermark.decode_watermark(image, self.length)
+ return StringOutput(value=watermark)
+
+
@invocation(
"mask_edge",
title="Mask Edge",
tags=["image", "mask", "inpaint"],
- category="image",
+ category="mask",
version="1.2.2",
)
class MaskEdgeInvocation(BaseInvocation, WithMetadata, WithBoard):
@@ -586,7 +723,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
"mask_combine",
title="Combine Masks",
tags=["image", "mask", "multiply"],
- category="image",
+ category="mask",
version="1.2.2",
)
class MaskCombineInvocation(BaseInvocation, WithMetadata, WithBoard):
@@ -611,102 +748,104 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
title="Color Correct",
tags=["image", "color"],
category="image",
- version="1.2.2",
+ version="2.0.0",
)
class ColorCorrectInvocation(BaseInvocation, WithMetadata, WithBoard):
"""
- Shifts the colors of a target image to match the reference image, optionally
- using a mask to only color-correct certain regions of the target image.
+ Matches the color histogram of a base image to a reference image, optionally
+ using a mask to only color-correct certain regions of the base image.
"""
- image: ImageField = InputField(description="The image to color-correct")
- reference: ImageField = InputField(description="Reference image for color-correction")
- mask: Optional[ImageField] = InputField(default=None, description="Mask to use when applying color-correction")
- mask_blur_radius: float = InputField(default=8, description="Mask blur radius")
+ base_image: ImageField = InputField(description="The image to color-correct")
+ color_reference: ImageField = InputField(description="Reference image for color-correction")
+ mask: Optional[ImageField] = InputField(default=None, description="Optional mask to limit color correction area")
+ colorspace: Literal["RGB", "YCbCr", "YCbCr-Chroma", "YCbCr-Luma"] = InputField(
+ default="RGB", description="Colorspace in which to apply histogram matching", title="Color Space"
+ )
+
+ def _match_histogram_channel(self, source: numpy.ndarray, reference: numpy.ndarray) -> numpy.ndarray:
+ """Match histogram of source channel to reference channel using cumulative distribution functions."""
+ # Compute histograms
+ source_hist, _ = numpy.histogram(source.flatten(), bins=256, range=(0, 256))
+ reference_hist, _ = numpy.histogram(reference.flatten(), bins=256, range=(0, 256))
+
+ # Compute cumulative distribution functions
+ source_cdf = source_hist.cumsum()
+ reference_cdf = reference_hist.cumsum()
+
+ # Normalize CDFs (avoid division by zero)
+ if source_cdf[-1] > 0:
+ source_cdf = source_cdf / source_cdf[-1]
+ if reference_cdf[-1] > 0:
+ reference_cdf = reference_cdf / reference_cdf[-1]
+
+ # Create lookup table using linear interpolation
+ lookup_table = numpy.interp(source_cdf, reference_cdf, numpy.arange(256))
+
+ # Apply lookup table to source image
+ return lookup_table[source].astype(numpy.uint8)
def invoke(self, context: InvocationContext) -> ImageOutput:
- pil_init_mask = None
- if self.mask is not None:
- pil_init_mask = context.images.get_pil(self.mask.image_name).convert("L")
-
- init_image = context.images.get_pil(self.reference.image_name)
-
- result = context.images.get_pil(self.image.image_name).convert("RGBA")
-
- # if init_image is None or init_mask is None:
- # return result
-
- # Get the original alpha channel of the mask if there is one.
- # Otherwise it is some other black/white image format ('1', 'L' or 'RGB')
- # pil_init_mask = (
- # init_mask.getchannel("A")
- # if init_mask.mode == "RGBA"
- # else init_mask.convert("L")
- # )
- pil_init_image = init_image.convert("RGBA") # Add an alpha channel if one doesn't exist
-
- # Build an image with only visible pixels from source to use as reference for color-matching.
- init_rgb_pixels = numpy.asarray(init_image.convert("RGB"), dtype=numpy.uint8)
- init_a_pixels = numpy.asarray(pil_init_image.getchannel("A"), dtype=numpy.uint8)
- init_mask_pixels = numpy.asarray(pil_init_mask, dtype=numpy.uint8)
-
- # Get numpy version of result
- np_image = numpy.asarray(result.convert("RGB"), dtype=numpy.uint8)
-
- # Mask and calculate mean and standard deviation
- mask_pixels = init_a_pixels * init_mask_pixels > 0
- np_init_rgb_pixels_masked = init_rgb_pixels[mask_pixels, :]
- np_image_masked = np_image[mask_pixels, :]
-
- if np_init_rgb_pixels_masked.size > 0:
- init_means = np_init_rgb_pixels_masked.mean(axis=0)
- init_std = np_init_rgb_pixels_masked.std(axis=0)
- gen_means = np_image_masked.mean(axis=0)
- gen_std = np_image_masked.std(axis=0)
-
- # Color correct
- np_matched_result = np_image.copy()
- np_matched_result[:, :, :] = (
- (
- (
- (np_matched_result[:, :, :].astype(numpy.float32) - gen_means[None, None, :])
- / gen_std[None, None, :]
- )
- * init_std[None, None, :]
- + init_means[None, None, :]
- )
- .clip(0, 255)
- .astype(numpy.uint8)
- )
- matched_result = Image.fromarray(np_matched_result, mode="RGB")
+ # Load images as RGBA
+ base_image = context.images.get_pil(self.base_image.image_name, "RGBA")
+
+ # Store original alpha channel
+ original_alpha = base_image.getchannel("A")
+
+ # Convert to working colorspace
+ if self.colorspace == "RGB":
+ base_array = numpy.asarray(base_image.convert("RGB"), dtype=numpy.uint8)
+ ref_rgb = context.images.get_pil(self.color_reference.image_name, "RGB")
+ ref_array = numpy.asarray(ref_rgb, dtype=numpy.uint8)
+ channels_to_match = [0, 1, 2] # R, G, B
else:
- matched_result = Image.fromarray(np_image, mode="RGB")
-
- # Blur the mask out (into init image) by specified amount
- if self.mask_blur_radius > 0:
- nm = numpy.asarray(pil_init_mask, dtype=numpy.uint8)
- inverted_nm = 255 - nm
- dilation_size = int(round(self.mask_blur_radius) + 20)
- dilating_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilation_size, dilation_size))
- inverted_dilated_nm = cv2.dilate(inverted_nm, dilating_kernel)
- dilated_nm = 255 - inverted_dilated_nm
- nmd = cv2.erode(
- dilated_nm,
- kernel=numpy.ones((3, 3), dtype=numpy.uint8),
- iterations=int(self.mask_blur_radius / 2),
+ # Convert to YCbCr colorspace
+ base_ycbcr = base_image.convert("YCbCr")
+ ref_ycbcr = context.images.get_pil(self.color_reference.image_name, "YCbCr")
+
+ base_array = numpy.asarray(base_ycbcr, dtype=numpy.uint8)
+ ref_array = numpy.asarray(ref_ycbcr, dtype=numpy.uint8)
+
+ # Determine which channels to match based on mode
+ if self.colorspace == "YCbCr":
+ channels_to_match = [0, 1, 2] # Y, Cb, Cr
+ elif self.colorspace == "YCbCr-Chroma":
+ channels_to_match = [1, 2] # Cb, Cr only
+ else: # YCbCr-Luma
+ channels_to_match = [0] # Y only
+
+ # Apply histogram matching to selected channels
+ corrected_array = base_array.copy()
+ for channel_idx in channels_to_match:
+ corrected_array[:, :, channel_idx] = self._match_histogram_channel(
+ base_array[:, :, channel_idx], ref_array[:, :, channel_idx]
)
- pmd = Image.fromarray(nmd, mode="L")
- blurred_init_mask = pmd.filter(ImageFilter.BoxBlur(self.mask_blur_radius))
- else:
- blurred_init_mask = pil_init_mask
- multiplied_blurred_init_mask = ImageChops.multiply(blurred_init_mask, result.split()[-1])
+ # Convert back to RGB if we were in YCbCr
+ if self.colorspace != "RGB":
+ corrected_image = Image.fromarray(corrected_array, mode="YCbCr").convert("RGB")
+ else:
+ corrected_image = Image.fromarray(corrected_array, mode="RGB")
- # Paste original on color-corrected generation (using blurred mask)
- matched_result.paste(init_image, (0, 0), mask=multiplied_blurred_init_mask)
+ # Apply mask if provided (white = original, black = result)
+ if self.mask is not None:
+ # Load mask as grayscale
+ mask_image = context.images.get_pil(self.mask.image_name, "L")
+ # Start with corrected image, paste base image where mask is white
+ result = corrected_image.copy()
+ if mask_image.size != result.size:
+ raise ValueError("Mask size must match base image size.")
+ else:
+ result.paste(base_image.convert("RGB"), mask=mask_image)
+ else:
+ result = corrected_image
- image_dto = context.images.save(image=matched_result)
+ # Convert to RGBA and restore original alpha
+ result = result.convert("RGBA")
+ result.putalpha(original_alpha)
+ # Save and return
+ image_dto = context.images.save(image=result)
return ImageOutput.build(image_dto)
@@ -743,6 +882,47 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
return ImageOutput.build(image_dto)
+@invocation(
+ "img_hue_adjust_oklch",
+ title="Adjust Image Hue (Oklch)",
+ tags=["image", "hue", "oklch"],
+ category="image",
+ version="1.0.0",
+)
+class OklchImageHueAdjustmentInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Adjusts the hue of an image in Oklch space."""
+
+ image: ImageField = InputField(description="The image to adjust")
+ hue: int = InputField(default=0, description="The degrees by which to rotate the hue, 0-360")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name)
+ mode = image.mode
+ alpha_channel = _extract_alpha_channel(image)
+
+ rgb = torch.from_numpy(numpy.asarray(image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
+ oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
+ oklch[2, ...] = (oklch[2, ...] + self.hue) % 360.0
+
+ image = _restore_original_mode(
+ Image.fromarray(
+ (
+ torch.clamp(srgb_from_linear_srgb(linear_srgb_from_oklch(oklch)), 0.0, 1.0)
+ .permute(1, 2, 0)
+ .cpu()
+ .numpy()
+ * 255.0
+ ).astype(numpy.uint8),
+ mode="RGB",
+ ),
+ mode,
+ alpha_channel,
+ )
+
+ image_dto = context.images.save(image=image)
+ return ImageOutput.build(image_dto)
+
+
COLOR_CHANNELS = Literal[
"Red (RGBA)",
"Green (RGBA)",
@@ -804,7 +984,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
"value",
],
category="image",
- version="1.2.2",
+ version="1.2.3",
)
class ImageChannelOffsetInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Add or subtract a value from a specific color channel of an image."""
@@ -814,18 +994,22 @@ class ImageChannelOffsetInvocation(BaseInvocation, WithMetadata, WithBoard):
offset: int = InputField(default=0, ge=-255, le=255, description="The amount to adjust the channel by")
def invoke(self, context: InvocationContext) -> ImageOutput:
- pil_image = context.images.get_pil(self.image.image_name)
+ image = context.images.get_pil(self.image.image_name, "RGBA")
# extract the channel and mode from the input and reference tuple
mode = CHANNEL_FORMATS[self.channel][0]
channel_number = CHANNEL_FORMATS[self.channel][1]
# Convert PIL image to new format
- converted_image = numpy.array(pil_image.convert(mode)).astype(int)
+ converted_image = numpy.array(image.convert(mode)).astype(int)
image_channel = converted_image[:, :, channel_number]
- # Adjust the value, clipping to 0..255
- image_channel = numpy.clip(image_channel + self.offset, 0, 255)
+ if self.channel == "Hue (HSV)":
+ # loop around the values because hue is special
+ image_channel = (image_channel + self.offset) % 256
+ else:
+ # Adjust the value, clipping to 0..255
+ image_channel = numpy.clip(image_channel + self.offset, 0, 255)
# Put the channel back into the image
converted_image[:, :, channel_number] = image_channel
@@ -833,6 +1017,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
# Convert back to RGBA format and output
pil_image = Image.fromarray(converted_image.astype(numpy.uint8), mode=mode).convert("RGBA")
+ # restore the alpha channel
+ if self.channel != "Alpha (RGBA)":
+ pil_image.putalpha(image.getchannel("A"))
+
image_dto = context.images.save(image=pil_image)
return ImageOutput.build(image_dto)
@@ -860,7 +1048,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
"value",
],
category="image",
- version="1.2.2",
+ version="1.2.3",
)
class ImageChannelMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Scale a specific color channel of an image."""
@@ -871,14 +1059,14 @@ class ImageChannelMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard):
invert_channel: bool = InputField(default=False, description="Invert the channel after scaling")
def invoke(self, context: InvocationContext) -> ImageOutput:
- pil_image = context.images.get_pil(self.image.image_name)
+ image = context.images.get_pil(self.image.image_name, "RGBA")
# extract the channel and mode from the input and reference tuple
mode = CHANNEL_FORMATS[self.channel][0]
channel_number = CHANNEL_FORMATS[self.channel][1]
# Convert PIL image to new format
- converted_image = numpy.array(pil_image.convert(mode)).astype(float)
+ converted_image = numpy.array(image.convert(mode)).astype(float)
image_channel = converted_image[:, :, channel_number]
# Adjust the value, clipping to 0..255
@@ -894,6 +1082,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
# Convert back to RGBA format and output
pil_image = Image.fromarray(converted_image.astype(numpy.uint8), mode=mode).convert("RGBA")
+ # restore the alpha channel
+ if self.channel != "Alpha (RGBA)":
+ pil_image.putalpha(image.getchannel("A"))
+
image_dto = context.images.save(image=pil_image)
return ImageOutput.build(image_dto)
@@ -903,7 +1095,7 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
"save_image",
title="Save Image",
tags=["primitives", "image"],
- category="primitives",
+ category="image",
version="1.2.2",
use_cache=False,
)
@@ -920,18 +1112,114 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
return ImageOutput.build(image_dto)
+@invocation(
+ "save_image_to_file",
+ title="Save Image (Gallery + File Export)",
+ tags=["image", "export", "file", "save"],
+ category="image",
+ version="1.0.0",
+ use_cache=False,
+)
+class SaveImageToFileInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Saves an image to the gallery (like the standard Save Image node) AND additionally exports a copy
+ to the filesystem with a custom filename.
+
+ Filename pattern: {prefix}{uuid}{suffix}.{file_format}
+ - The UUID is the same UUID used for the gallery entry, so the exported file can be matched to the gallery item.
+ - The gallery entry itself always uses the plain UUID (prefix/suffix apply only to the exported file on disk).
+ - Board and Metadata inputs behave exactly like the standard Save Image node.
+ - The export target is restricted to (subfolders of) the InvokeAI outputs folder — absolute paths are rejected.
+
+ Example: prefix="hero_", suffix="_final", file_format="png" → "hero__final.png"
+ """
+
+ image: ImageField = InputField(description="The image to save and export")
+ output_directory: str = InputField(
+ default="",
+ description=(
+ "Target subdirectory (relative to the configured InvokeAI outputs folder) for the exported file. "
+ "Leave empty to use the outputs folder directly. "
+ "Example: 'my-exports' → /my-exports/. Nested paths like 'exports/2026' are allowed. "
+ "Absolute paths and path traversal ('..') are not allowed for security reasons. "
+ "The directory is created automatically if it doesn't exist."
+ ),
+ )
+ prefix: str = InputField(
+ default="",
+ description="Text prepended to the UUID in the exported filename. Example: 'portrait_' → 'portrait_.png'",
+ )
+ suffix: str = InputField(
+ default="",
+ description="Text appended to the UUID (before the extension). Example: '_v2' → '_v2.png'",
+ )
+ file_format: Literal["png", "jpg", "webp"] = InputField(
+ default="png",
+ description="File format for the exported file. PNG is lossless; JPG/WEBP are lossy and respect 'quality'.",
+ )
+ quality: int = InputField(
+ default=95,
+ ge=1,
+ le=100,
+ description="Compression quality for JPG and WEBP (1-100, higher = better quality, larger file). Ignored for PNG.",
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_dto = context.images.save(image=image)
+
+ uuid = Path(image_dto.image_name).stem
+
+ outputs_path = context.config.get().outputs_path
+ assert outputs_path is not None
+
+ if not self.output_directory:
+ target_dir = outputs_path
+ else:
+ raw_str = self.output_directory
+ raw = Path(raw_str)
+ has_windows_drive = len(raw_str) >= 2 and raw_str[0].isalpha() and raw_str[1] == ":"
+ starts_with_sep = raw_str.startswith("/") or raw_str.startswith("\\")
+ if raw.is_absolute() or raw.drive or has_windows_drive or starts_with_sep:
+ raise ValueError(
+ f"Absolute paths are not allowed in output_directory: {raw_str!r}. "
+ "Use a path relative to the InvokeAI outputs folder."
+ )
+ candidate = (outputs_path / raw).resolve()
+ outputs_resolved = outputs_path.resolve()
+ if outputs_resolved != candidate and outputs_resolved not in candidate.parents:
+ raise ValueError(f"output_directory must stay within the outputs folder: {raw_str!r}")
+ target_dir = candidate
+
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ filename = f"{self.prefix}{uuid}{self.suffix}.{self.file_format}"
+ target_path = target_dir / filename
+
+ if self.file_format == "png":
+ image.save(target_path, format="PNG")
+ elif self.file_format == "jpg":
+ if image.mode in ("RGBA", "LA", "P"):
+ image = image.convert("RGB")
+ image.save(target_path, format="JPEG", quality=self.quality)
+ else:
+ image.save(target_path, format="WEBP", quality=self.quality)
+
+ return ImageOutput.build(image_dto)
+
+
@invocation(
"canvas_paste_back",
title="Canvas Paste Back",
tags=["image", "combine"],
- category="image",
- version="1.0.0",
+ category="canvas",
+ version="1.0.1",
)
class CanvasPasteBackInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Combines two images by using the mask provided. Intended for use on the Unified Canvas."""
source_image: ImageField = InputField(description="The source image")
- target_image: ImageField = InputField(default=None, description="The target image")
+ target_image: ImageField = InputField(description="The target image")
mask: ImageField = InputField(
description="The mask to use when pasting",
)
@@ -959,10 +1247,10 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
@invocation(
"mask_from_id",
- title="Mask from ID",
+ title="Mask from Segmented Image",
tags=["image", "mask", "id"],
- category="image",
- version="1.0.0",
+ category="mask",
+ version="1.0.1",
)
class MaskFromIDInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Generate a mask for a particular color in an ID Map"""
@@ -972,39 +1260,421 @@ class MaskFromIDInvocation(BaseInvocation, WithMetadata, WithBoard):
threshold: int = InputField(default=100, description="Threshold for color detection")
invert: bool = InputField(default=False, description="Whether or not to invert the mask")
- def rgba_to_hex(self, rgba_color: tuple[int, int, int, int]):
- r, g, b, a = rgba_color
- hex_code = "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, int(a * 255))
- return hex_code
-
- def id_to_mask(self, id_mask: Image.Image, color: tuple[int, int, int, int], threshold: int = 100):
- if id_mask.mode != "RGB":
- id_mask = id_mask.convert("RGB")
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
- # Can directly just use the tuple but I'll leave this rgba_to_hex here
- # incase anyone prefers using hex codes directly instead of the color picker
- hex_color_str = self.rgba_to_hex(color)
- rgb_color = numpy.array([int(hex_color_str[i : i + 2], 16) for i in (1, 3, 5)])
+ np_color = numpy.array(self.color.tuple())
# Maybe there's a faster way to calculate this distance but I can't think of any right now.
- color_distance = numpy.linalg.norm(id_mask - rgb_color, axis=-1)
+ color_distance = numpy.linalg.norm(image - np_color, axis=-1)
# Create a mask based on the threshold and the distance calculated above
- binary_mask = (color_distance < threshold).astype(numpy.uint8) * 255
+ binary_mask = (color_distance < self.threshold).astype(numpy.uint8) * 255
# Convert the mask back to PIL
binary_mask_pil = Image.fromarray(binary_mask)
- return binary_mask_pil
+ if self.invert:
+ binary_mask_pil = ImageOps.invert(binary_mask_pil)
+
+ image_dto = context.images.save(image=binary_mask_pil, image_category=ImageCategory.MASK)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "canvas_v2_mask_and_crop",
+ title="Canvas V2 Mask and Crop",
+ tags=["image", "mask", "id"],
+ category="canvas",
+ version="1.0.0",
+ classification=Classification.Deprecated,
+)
+class CanvasV2MaskAndCropInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Handles Canvas V2 image output masking and cropping"""
+
+ source_image: ImageField | None = InputField(
+ default=None,
+ description="The source image onto which the masked generated image is pasted. If omitted, the masked generated image is returned with transparency.",
+ )
+ generated_image: ImageField = InputField(description="The image to apply the mask to")
+ mask: ImageField = InputField(description="The mask to apply")
+ mask_blur: int = InputField(default=0, ge=0, description="The amount to blur the mask by")
+
+ def _prepare_mask(self, mask: Image.Image) -> Image.Image:
+ mask_array = numpy.array(mask)
+ kernel = numpy.ones((self.mask_blur, self.mask_blur), numpy.uint8)
+ dilated_mask_array = cv2.erode(mask_array, kernel, iterations=3)
+ dilated_mask = Image.fromarray(dilated_mask_array)
+ if self.mask_blur > 0:
+ mask = dilated_mask.filter(ImageFilter.GaussianBlur(self.mask_blur))
+ return ImageOps.invert(mask.convert("L"))
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ mask = self._prepare_mask(context.images.get_pil(self.mask.image_name))
+
+ if self.source_image:
+ generated_image = context.images.get_pil(self.generated_image.image_name)
+ source_image = context.images.get_pil(self.source_image.image_name)
+ source_image.paste(generated_image, (0, 0), mask)
+ image_dto = context.images.save(image=source_image)
+ else:
+ generated_image = context.images.get_pil(self.generated_image.image_name)
+ generated_image.putalpha(mask)
+ image_dto = context.images.save(image=generated_image)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "expand_mask_with_fade", title="Expand Mask with Fade", tags=["image", "mask"], category="mask", version="1.0.1"
+)
+class ExpandMaskWithFadeInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Expands a mask with a fade effect. The mask uses black to indicate areas to keep from the generated image and white for areas to discard.
+ The mask is thresholded to create a binary mask, and then a distance transform is applied to create a fade effect.
+ The fade size is specified in pixels, and the mask is expanded by that amount. The result is a mask with a smooth transition from black to white.
+ If the fade size is 0, the mask is returned as-is.
+ """
+
+ mask: ImageField = InputField(description="The mask to expand")
+ threshold: int = InputField(default=0, ge=0, le=255, description="The threshold for the binary mask (0-255)")
+ fade_size_px: int = InputField(default=32, ge=0, description="The size of the fade in pixels")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ pil_mask = context.images.get_pil(self.mask.image_name, mode="L")
+
+ if self.fade_size_px == 0:
+ # If the fade size is 0, just return the mask as-is.
+ image_dto = context.images.save(image=pil_mask, image_category=ImageCategory.MASK)
+ return ImageOutput.build(image_dto)
+
+ np_mask = numpy.array(pil_mask)
+
+ # Threshold the mask to create a binary mask - 0 for black, 255 for white
+ # If we don't threshold we can get some weird artifacts
+ np_mask = numpy.where(np_mask > self.threshold, 255, 0).astype(numpy.uint8)
+
+ # Create a mask for the black region (1 where black, 0 otherwise)
+ black_mask = (np_mask == 0).astype(numpy.uint8)
+
+ # Invert the black region
+ bg_mask = 1 - black_mask
+
+ # Create a distance transform of the inverted mask
+ dist = cv2.distanceTransform(bg_mask, cv2.DIST_L2, 5)
+
+ # Normalize distances so that pixels = 1.0, 1.0, feather)
+
+ # Clip any other values to ensure they're in the valid range [0,1]
+ feather = numpy.clip(feather, 0, 1)
+
+ # Build final image.
+ np_result = numpy.where(black_mask == 1, 0, (feather * 255).astype(numpy.uint8))
+
+ # Convert back to PIL, grayscale
+ pil_result = Image.fromarray(np_result.astype(numpy.uint8), mode="L")
+
+ image_dto = context.images.save(image=pil_result, image_category=ImageCategory.MASK)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "apply_mask_to_image",
+ title="Apply Mask to Image",
+ tags=["image", "mask", "blend"],
+ category="mask",
+ version="1.0.0",
+)
+class ApplyMaskToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """
+ Extracts a region from a generated image using a mask and blends it seamlessly onto a source image.
+ The mask uses black to indicate areas to keep from the generated image and white for areas to discard.
+ """
+
+ image: ImageField = InputField(description="The image from which to extract the masked region")
+ mask: ImageField = InputField(description="The mask defining the region (black=keep, white=discard)")
+ invert_mask: bool = InputField(
+ default=False,
+ description="Whether to invert the mask before applying it",
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ # Load images
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
+ mask = context.images.get_pil(self.mask.image_name, mode="L")
+
+ if self.invert_mask:
+ # Invert the mask if requested
+ mask = ImageOps.invert(mask.copy())
+
+ # Combine the mask as the alpha channel of the image
+ r, g, b, _ = image.split() # Split the image into RGB and alpha channels
+ result_image = Image.merge("RGBA", (r, g, b, mask)) # Use the mask as the new alpha channel
+
+ # Save the resulting image
+ image_dto = context.images.save(image=result_image)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "img_noise",
+ title="Add Image Noise",
+ tags=["image", "noise"],
+ category="image",
+ version="1.1.0",
+)
+class ImageNoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Add noise to an image"""
+
+ image: ImageField = InputField(description="The image to add noise to")
+ mask: Optional[ImageField] = InputField(
+ default=None, description="Optional mask determining where to apply noise (black=noise, white=no noise)"
+ )
+ seed: int = InputField(
+ default=0,
+ ge=0,
+ le=SEED_MAX,
+ description=FieldDescriptions.seed,
+ )
+ noise_type: Literal["gaussian", "salt_and_pepper"] = InputField(
+ default="gaussian",
+ description="The type of noise to add",
+ )
+ amount: float = InputField(default=0.1, ge=0, le=1, description="The amount of noise to add")
+ noise_color: bool = InputField(default=True, description="Whether to add colored noise")
+ size: int = InputField(default=1, ge=1, description="The size of the noise points")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
+
+ # Save out the alpha channel
+ alpha = image.getchannel("A")
+
+ # Set the seed for numpy random
+ rs = numpy.random.RandomState(numpy.random.MT19937(numpy.random.SeedSequence(self.seed)))
+
+ if self.noise_type == "gaussian":
+ if self.noise_color:
+ noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size, 3)) * 255
+ else:
+ noise = rs.normal(0, 1, (image.height // self.size, image.width // self.size)) * 255
+ noise = numpy.stack([noise] * 3, axis=-1)
+ elif self.noise_type == "salt_and_pepper":
+ if self.noise_color:
+ noise = rs.choice(
+ [0, 255], (image.height // self.size, image.width // self.size, 3), p=[1 - self.amount, self.amount]
+ )
+ else:
+ noise = rs.choice(
+ [0, 255], (image.height // self.size, image.width // self.size), p=[1 - self.amount, self.amount]
+ )
+ noise = numpy.stack([noise] * 3, axis=-1)
+
+ noise = Image.fromarray(noise.astype(numpy.uint8), mode="RGB").resize(
+ (image.width, image.height), Image.Resampling.NEAREST
+ )
+
+ # Create a noisy version of the input image
+ noisy_image = Image.blend(image.convert("RGB"), noise, self.amount).convert("RGBA")
+
+ # Apply mask if provided
+ if self.mask is not None:
+ mask_image = context.images.get_pil(self.mask.image_name, mode="L")
+
+ if mask_image.size != image.size:
+ mask_image = mask_image.resize(image.size, Image.Resampling.LANCZOS)
+
+ result_image = image.copy()
+ mask_image = ImageOps.invert(mask_image)
+ result_image.paste(noisy_image, (0, 0), mask=mask_image)
+ else:
+ result_image = noisy_image
+
+ # Paste back the alpha channel from the original image
+ result_image.putalpha(alpha)
+
+ image_dto = context.images.save(image=result_image)
+
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "crop_image_to_bounding_box",
+ title="Crop Image to Bounding Box",
+ category="image",
+ version="1.0.0",
+ tags=["image", "crop"],
+)
+class CropImageToBoundingBoxInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Crop an image to the given bounding box. If the bounding box is omitted, the image is cropped to the non-transparent pixels."""
+
+ image: ImageField = InputField(description="The image to crop")
+ bounding_box: BoundingBoxField | None = InputField(
+ default=None, description="The bounding box to crop the image to"
+ )
def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)
- mask = self.id_to_mask(image, self.color.tuple(), self.threshold)
+ bounding_box = self.bounding_box.tuple() if self.bounding_box is not None else image.getbbox()
- if self.invert:
- mask = ImageOps.invert(mask)
+ cropped_image = image.crop(bounding_box)
+
+ image_dto = context.images.save(image=cropped_image)
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "paste_image_into_bounding_box",
+ title="Paste Image into Bounding Box",
+ category="image",
+ version="1.0.0",
+ tags=["image", "crop"],
+)
+class PasteImageIntoBoundingBoxInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Paste the source image into the target image at the given bounding box.
- image_dto = context.images.save(image=mask, image_category=ImageCategory.MASK)
+ The source image must be the same size as the bounding box, and the bounding box must fit within the target image."""
+ source_image: ImageField = InputField(description="The image to paste")
+ target_image: ImageField = InputField(description="The image to paste into")
+ bounding_box: BoundingBoxField = InputField(description="The bounding box to paste the image into")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ source_image = context.images.get_pil(self.source_image.image_name, mode="RGBA")
+ target_image = context.images.get_pil(self.target_image.image_name, mode="RGBA")
+
+ bounding_box = self.bounding_box.tuple()
+
+ target_image.paste(source_image, bounding_box, source_image)
+
+ image_dto = context.images.save(image=target_image)
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "flux_kontext_image_prep",
+ title="FLUX Kontext Image Prep",
+ tags=["image", "concatenate", "flux", "kontext"],
+ category="conditioning",
+ version="1.0.0",
+)
+class FluxKontextConcatenateImagesInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Prepares an image or images for use with FLUX Kontext. The first/single image is resized to the nearest
+ preferred Kontext resolution. All other images are concatenated horizontally, maintaining their aspect ratio."""
+
+ images: list[ImageField] = InputField(
+ description="The images to concatenate",
+ min_length=1,
+ max_length=10,
+ )
+
+ use_preferred_resolution: bool = InputField(
+ default=True, description="Use FLUX preferred resolutions for the first image"
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ from invokeai.backend.flux.util import PREFERED_KONTEXT_RESOLUTIONS
+
+ # Step 1: Load all images
+ pil_images = []
+ for image_field in self.images:
+ image = context.images.get_pil(image_field.image_name, mode="RGBA")
+ pil_images.append(image)
+
+ # Step 2: Determine target resolution for the first image
+ first_image = pil_images[0]
+ width, height = first_image.size
+
+ if self.use_preferred_resolution:
+ aspect_ratio = width / height
+
+ # Find the closest preferred resolution for the first image
+ _, target_width, target_height = min(
+ ((abs(aspect_ratio - w / h), w, h) for w, h in PREFERED_KONTEXT_RESOLUTIONS), key=lambda x: x[0]
+ )
+
+ # Apply BFL's scaling formula
+ scaled_height = 2 * int(target_height / 16)
+ final_height = 8 * scaled_height # This will be consistent for all images
+ scaled_width = 2 * int(target_width / 16)
+ first_width = 8 * scaled_width
+ else:
+ # Use original dimensions of first image, ensuring divisibility by 16
+ final_height = 16 * (height // 16)
+ first_width = 16 * (width // 16)
+ # Ensure minimum dimensions
+ if final_height < 16:
+ final_height = 16
+ if first_width < 16:
+ first_width = 16
+
+ # Step 3: Process and resize all images with consistent height
+ processed_images = []
+ total_width = 0
+
+ for i, image in enumerate(pil_images):
+ if i == 0:
+ # First image uses the calculated dimensions
+ final_width = first_width
+ else:
+ # Subsequent images maintain aspect ratio with the same height
+ img_aspect_ratio = image.width / image.height
+ # Calculate width that maintains aspect ratio at the target height
+ calculated_width = int(final_height * img_aspect_ratio)
+ # Ensure width is divisible by 16 for proper VAE encoding
+ final_width = 16 * (calculated_width // 16)
+ # Ensure minimum width
+ if final_width < 16:
+ final_width = 16
+
+ # Resize image to calculated dimensions
+ resized_image = image.resize((final_width, final_height), Image.Resampling.LANCZOS)
+ processed_images.append(resized_image)
+ total_width += final_width
+
+ # Step 4: Concatenate images horizontally
+ concatenated_image = Image.new("RGB", (total_width, final_height))
+ x_offset = 0
+ for img in processed_images:
+ concatenated_image.paste(img, (x_offset, 0))
+ x_offset += img.width
+
+ # Save the concatenated image
+ image_dto = context.images.save(image=concatenated_image)
return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/image_panels.py b/invokeai/app/invocations/image_panels.py
new file mode 100644
index 00000000000..71fefbd1c6a
--- /dev/null
+++ b/invokeai/app/invocations/image_panels.py
@@ -0,0 +1,59 @@
+from pydantic import ValidationInfo, field_validator
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import InputField, OutputField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("image_panel_coordinate_output")
+class ImagePanelCoordinateOutput(BaseInvocationOutput):
+ x_left: int = OutputField(description="The left x-coordinate of the panel.")
+ y_top: int = OutputField(description="The top y-coordinate of the panel.")
+ width: int = OutputField(description="The width of the panel.")
+ height: int = OutputField(description="The height of the panel.")
+
+
+@invocation(
+ "image_panel_layout",
+ title="Image Panel Layout",
+ tags=["image", "panel", "layout"],
+ category="canvas",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class ImagePanelLayoutInvocation(BaseInvocation):
+ """Get the coordinates of a single panel in a grid. (If the full image shape cannot be divided evenly into panels,
+ then the grid may not cover the entire image.)
+ """
+
+ width: int = InputField(description="The width of the entire grid.")
+ height: int = InputField(description="The height of the entire grid.")
+ num_cols: int = InputField(ge=1, default=1, description="The number of columns in the grid.")
+ num_rows: int = InputField(ge=1, default=1, description="The number of rows in the grid.")
+ panel_col_idx: int = InputField(ge=0, default=0, description="The column index of the panel to be processed.")
+ panel_row_idx: int = InputField(ge=0, default=0, description="The row index of the panel to be processed.")
+
+ @field_validator("panel_col_idx")
+ def validate_panel_col_idx(cls, v: int, info: ValidationInfo) -> int:
+ if v < 0 or v >= info.data["num_cols"]:
+ raise ValueError(f"panel_col_idx must be between 0 and {info.data['num_cols'] - 1}")
+ return v
+
+ @field_validator("panel_row_idx")
+ def validate_panel_row_idx(cls, v: int, info: ValidationInfo) -> int:
+ if v < 0 or v >= info.data["num_rows"]:
+ raise ValueError(f"panel_row_idx must be between 0 and {info.data['num_rows'] - 1}")
+ return v
+
+ def invoke(self, context: InvocationContext) -> ImagePanelCoordinateOutput:
+ x_left = self.panel_col_idx * (self.width // self.num_cols)
+ y_top = self.panel_row_idx * (self.height // self.num_rows)
+ width = self.width // self.num_cols
+ height = self.height // self.num_rows
+ return ImagePanelCoordinateOutput(x_left=x_left, y_top=y_top, width=width, height=height)
diff --git a/invokeai/app/invocations/image_to_latents.py b/invokeai/app/invocations/image_to_latents.py
index 06de530154e..8dc5ceba0b0 100644
--- a/invokeai/app/invocations/image_to_latents.py
+++ b/invokeai/app/invocations/image_to_latents.py
@@ -1,4 +1,6 @@
+from contextlib import nullcontext
from functools import singledispatchmethod
+from typing import Literal
import einops
import torch
@@ -12,26 +14,37 @@
from diffusers.models.autoencoders.autoencoder_tiny import AutoencoderTiny
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-from invokeai.app.invocations.constants import DEFAULT_PRECISION
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import (
FieldDescriptions,
ImageField,
Input,
InputField,
)
-from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.model import BaseModelType, VAEField
from invokeai.app.invocations.primitives import LatentsOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager import LoadedModel
+from invokeai.backend.model_manager.load.load_base import LoadedModel
from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.stable_diffusion.vae_tiling import patch_vae_tiling_params
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_sd15_sdxl
+
+"""
+SDXL VAE color compensation values determined experimentally to reduce color drift.
+If more reliable values are found in the future (e.g. individual color channels), they can be updated.
+SD1.5, TAESD, TAESDXL VAEs distort in less predictable ways, so no compensation is offered at this time.
+"""
+COMPENSATION_OPTIONS = Literal["None", "SDXL"]
+COLOR_COMPENSATION_MAP = {"None": [1, 0], "SDXL": [1.015, -0.002]}
@invocation(
"i2l",
- title="Image to Latents",
+ title="Image to Latents - SD1.5, SDXL",
tags=["latents", "image", "vae", "i2l"],
category="latents",
- version="1.0.2",
+ version="1.2.0",
)
class ImageToLatentsInvocation(BaseInvocation):
"""Encodes an image into latents."""
@@ -44,12 +57,34 @@ class ImageToLatentsInvocation(BaseInvocation):
input=Input.Connection,
)
tiled: bool = InputField(default=False, description=FieldDescriptions.tiled)
- fp32: bool = InputField(default=DEFAULT_PRECISION == torch.float32, description=FieldDescriptions.fp32)
+ # NOTE: tile_size = 0 is a special value. We use this rather than `int | None`, because the workflow UI does not
+ # offer a way to directly set None values.
+ tile_size: int = InputField(default=0, multiple_of=8, description=FieldDescriptions.vae_tile_size)
+ fp32: bool = InputField(default=False, description=FieldDescriptions.fp32)
+ color_compensation: COMPENSATION_OPTIONS = InputField(
+ default="None",
+ description="Apply VAE scaling compensation when encoding images (reduces color drift).",
+ )
- @staticmethod
- def vae_encode(vae_info: LoadedModel, upcast: bool, tiled: bool, image_tensor: torch.Tensor) -> torch.Tensor:
- with vae_info as vae:
- assert isinstance(vae, torch.nn.Module)
+ @classmethod
+ def vae_encode(
+ cls,
+ vae_info: LoadedModel,
+ upcast: bool,
+ tiled: bool,
+ image_tensor: torch.Tensor,
+ tile_size: int = 0,
+ ) -> torch.Tensor:
+ assert isinstance(vae_info.model, (AutoencoderKL, AutoencoderTiny)), "VAE must be of type SD-1.5 or SDXL"
+ estimated_working_memory = estimate_vae_working_memory_sd15_sdxl(
+ operation="encode",
+ image_tensor=image_tensor,
+ vae=vae_info.model,
+ tile_size=tile_size if tiled else None,
+ fp32=upcast,
+ )
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, (AutoencoderKL, AutoencoderTiny)), "VAE must be of type SD-1.5 or SDXL"
orig_dtype = vae.dtype
if upcast:
vae.to(dtype=torch.float32)
@@ -81,9 +116,18 @@ def vae_encode(vae_info: LoadedModel, upcast: bool, tiled: bool, image_tensor: t
else:
vae.disable_tiling()
+ tiling_context = nullcontext()
+ if tile_size > 0:
+ tiling_context = patch_vae_tiling_params(
+ vae,
+ tile_sample_min_size=tile_size,
+ tile_latent_min_size=tile_size // LATENT_SCALE_FACTOR,
+ tile_overlap_factor=0.25,
+ )
+
# non_noised_latents_from_image
- image_tensor = image_tensor.to(device=vae.device, dtype=vae.dtype)
- with torch.inference_mode():
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae.dtype)
+ with torch.inference_mode(), tiling_context:
latents = ImageToLatentsInvocation._encode_to_tensor(vae, image_tensor)
latents = vae.config.scaling_factor * latents
@@ -96,12 +140,25 @@ def invoke(self, context: InvocationContext) -> LatentsOutput:
image = context.images.get_pil(self.image.image_name)
vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, (AutoencoderKL, AutoencoderTiny)), "VAE must be of type SD-1.5 or SDXL"
image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+
+ if self.color_compensation != "None" and vae_info.config.base == BaseModelType.StableDiffusionXL:
+ scale, bias = COLOR_COMPENSATION_MAP[self.color_compensation]
+ image_tensor = image_tensor * scale + bias
+
if image_tensor.dim() == 3:
image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
- latents = self.vae_encode(vae_info, self.fp32, self.tiled, image_tensor)
+ context.util.signal_progress("Running VAE encoder")
+ latents = self.vae_encode(
+ vae_info=vae_info,
+ upcast=self.fp32,
+ tiled=self.tiled or context.config.get().force_tiled_decode,
+ image_tensor=image_tensor,
+ tile_size=self.tile_size,
+ )
latents = latents.to("cpu")
name = context.tensors.save(tensor=latents)
diff --git a/invokeai/app/invocations/infill.py b/invokeai/app/invocations/infill.py
index 7e1a2ee322f..dd7b2c87b0a 100644
--- a/invokeai/app/invocations/infill.py
+++ b/invokeai/app/invocations/infill.py
@@ -3,7 +3,9 @@
from PIL import Image
-from invokeai.app.invocations.fields import ColorField, ImageField
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ColorField, ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.image import PIL_RESAMPLING_MAP, PIL_RESAMPLING_MODES
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.misc import SEED_MAX
@@ -14,10 +16,6 @@
from invokeai.backend.image_util.infill_methods.tile import infill_tile
from invokeai.backend.util.logging import InvokeAILogger
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField, WithBoard, WithMetadata
-from .image import PIL_RESAMPLING_MAP, PIL_RESAMPLING_MODES
-
logger = InvokeAILogger.get_logger()
@@ -129,13 +127,16 @@ def infill(self, image: Image.Image):
return infilled
+LAMA_MODEL_URL = "https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt"
+
+
@invocation("infill_lama", title="LaMa Infill", tags=["image", "inpaint"], category="inpaint", version="1.2.2")
class LaMaInfillInvocation(InfillImageProcessorInvocation):
"""Infills transparent areas of an image using the LaMa model"""
def infill(self, image: Image.Image):
with self._context.models.load_remote_model(
- source="https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt",
+ source=LAMA_MODEL_URL,
loader=LaMA.load_jit_model,
) as model:
lama = LaMA(model)
diff --git a/invokeai/app/invocations/ip_adapter.py b/invokeai/app/invocations/ip_adapter.py
index de40879eef8..711f910d587 100644
--- a/invokeai/app/invocations/ip_adapter.py
+++ b/invokeai/app/invocations/ip_adapter.py
@@ -5,18 +5,24 @@
from typing_extensions import Self
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
-from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField, TensorField, UIType
+from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField, TensorField
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.primitives import ImageField
from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
+from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager.config import (
- AnyModelConfig,
- BaseModelType,
- IPAdapterCheckpointConfig,
- IPAdapterInvokeAIConfig,
- ModelType,
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.configs.ip_adapter import (
+ IPAdapter_Checkpoint_Config_Base,
+ IPAdapter_InvokeAI_Config_Base,
)
+from invokeai.backend.model_manager.starter_models import (
+ StarterModel,
+ clip_vit_l_image_encoder,
+ ip_adapter_sd_image_encoder,
+ ip_adapter_sdxl_image_encoder,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
class IPAdapterField(BaseModel):
@@ -25,6 +31,7 @@ class IPAdapterField(BaseModel):
image_encoder_model: ModelIdentifierField = Field(description="The name of the CLIP image encoder model.")
weight: Union[float, List[float]] = Field(default=1, description="The weight given to the IP-Adapter.")
target_blocks: List[str] = Field(default=[], description="The IP Adapter blocks to apply")
+ method: str = Field(default="full", description="Weight apply method")
begin_step_percent: float = Field(
default=0, ge=0, le=1, description="When the IP-Adapter is first applied (% of total steps)"
)
@@ -55,10 +62,20 @@ class IPAdapterOutput(BaseInvocationOutput):
ip_adapter: IPAdapterField = OutputField(description=FieldDescriptions.ip_adapter, title="IP-Adapter")
-CLIP_VISION_MODEL_MAP = {"ViT-H": "ip_adapter_sd_image_encoder", "ViT-G": "ip_adapter_sdxl_image_encoder"}
+CLIP_VISION_MODEL_MAP: dict[Literal["ViT-L", "ViT-H", "ViT-G"], StarterModel] = {
+ "ViT-L": clip_vit_l_image_encoder,
+ "ViT-H": ip_adapter_sd_image_encoder,
+ "ViT-G": ip_adapter_sdxl_image_encoder,
+}
-@invocation("ip_adapter", title="IP-Adapter", tags=["ip_adapter", "control"], category="ip_adapter", version="1.4.1")
+@invocation(
+ "ip_adapter",
+ title="IP-Adapter - SD1.5, SDXL",
+ tags=["ip_adapter", "control"],
+ category="conditioning",
+ version="1.5.1",
+)
class IPAdapterInvocation(BaseInvocation):
"""Collects IP-Adapter info to pass to other nodes."""
@@ -68,9 +85,10 @@ class IPAdapterInvocation(BaseInvocation):
description="The IP-Adapter model.",
title="IP-Adapter Model",
ui_order=-1,
- ui_type=UIType.IPAdapterModel,
+ ui_model_base=[BaseModelType.StableDiffusion1, BaseModelType.StableDiffusionXL],
+ ui_model_type=ModelType.IPAdapter,
)
- clip_vision_model: Literal["ViT-H", "ViT-G"] = InputField(
+ clip_vision_model: Literal["ViT-H", "ViT-G", "ViT-L"] = InputField(
description="CLIP Vision model to use. Overrides model settings. Mandatory for checkpoint models.",
default="ViT-H",
ui_order=2,
@@ -78,7 +96,7 @@ class IPAdapterInvocation(BaseInvocation):
weight: Union[float, List[float]] = InputField(
default=1, description="The weight given to the IP-Adapter", title="Weight"
)
- method: Literal["full", "style", "composition"] = InputField(
+ method: Literal["full", "style", "composition", "style_strong", "style_precise"] = InputField(
default="full", description="The method to apply the IP-Adapter"
)
begin_step_percent: float = InputField(
@@ -105,15 +123,17 @@ def validate_begin_end_step_percent(self) -> Self:
def invoke(self, context: InvocationContext) -> IPAdapterOutput:
# Lookup the CLIP Vision encoder that is intended to be used with the IP-Adapter model.
ip_adapter_info = context.models.get_config(self.ip_adapter_model.key)
- assert isinstance(ip_adapter_info, (IPAdapterInvokeAIConfig, IPAdapterCheckpointConfig))
+ assert isinstance(ip_adapter_info, (IPAdapter_InvokeAI_Config_Base, IPAdapter_Checkpoint_Config_Base))
- if isinstance(ip_adapter_info, IPAdapterInvokeAIConfig):
+ if isinstance(ip_adapter_info, IPAdapter_InvokeAI_Config_Base):
image_encoder_model_id = ip_adapter_info.image_encoder_model_id
image_encoder_model_name = image_encoder_model_id.split("/")[-1].strip()
else:
- image_encoder_model_name = CLIP_VISION_MODEL_MAP[self.clip_vision_model]
+ image_encoder_starter_model = CLIP_VISION_MODEL_MAP[self.clip_vision_model]
+ image_encoder_model_id = image_encoder_starter_model.source
+ image_encoder_model_name = image_encoder_starter_model.name
- image_encoder_model = self._get_image_encoder(context, image_encoder_model_name)
+ image_encoder_model = self.get_clip_image_encoder(context, image_encoder_model_id, image_encoder_model_name)
if self.method == "style":
if ip_adapter_info.base == "sd-1":
@@ -129,6 +149,38 @@ def invoke(self, context: InvocationContext) -> IPAdapterOutput:
target_blocks = ["down_blocks.2.attentions.1"]
else:
raise ValueError(f"Unsupported IP-Adapter base type: '{ip_adapter_info.base}'.")
+ elif self.method == "style_precise":
+ if ip_adapter_info.base == "sd-1":
+ target_blocks = ["up_blocks.1", "down_blocks.2", "mid_block"]
+ elif ip_adapter_info.base == "sdxl":
+ target_blocks = ["up_blocks.0.attentions.1", "down_blocks.2.attentions.1"]
+ else:
+ raise ValueError(f"Unsupported IP-Adapter base type: '{ip_adapter_info.base}'.")
+ elif self.method == "style_strong":
+ if ip_adapter_info.base == "sd-1":
+ target_blocks = ["up_blocks.0", "up_blocks.1", "up_blocks.2", "down_blocks.0", "down_blocks.1"]
+ elif ip_adapter_info.base == "sdxl":
+ target_blocks = [
+ "up_blocks.0.attentions.1",
+ "up_blocks.1.attentions.1",
+ "up_blocks.2.attentions.1",
+ "up_blocks.0.attentions.2",
+ "up_blocks.1.attentions.2",
+ "up_blocks.2.attentions.2",
+ "up_blocks.0.attentions.0",
+ "up_blocks.1.attentions.0",
+ "up_blocks.2.attentions.0",
+ "down_blocks.0.attentions.0",
+ "down_blocks.0.attentions.1",
+ "down_blocks.0.attentions.2",
+ "down_blocks.1.attentions.0",
+ "down_blocks.1.attentions.1",
+ "down_blocks.1.attentions.2",
+ "down_blocks.2.attentions.0",
+ "down_blocks.2.attentions.2",
+ ]
+ else:
+ raise ValueError(f"Unsupported IP-Adapter base type: '{ip_adapter_info.base}'.")
elif self.method == "full":
target_blocks = ["block"]
else:
@@ -144,10 +196,14 @@ def invoke(self, context: InvocationContext) -> IPAdapterOutput:
begin_step_percent=self.begin_step_percent,
end_step_percent=self.end_step_percent,
mask=self.mask,
+ method=self.method,
),
)
- def _get_image_encoder(self, context: InvocationContext, image_encoder_model_name: str) -> AnyModelConfig:
+ @classmethod
+ def get_clip_image_encoder(
+ cls, context: InvocationContext, image_encoder_model_id: str, image_encoder_model_name: str
+ ) -> AnyModelConfig:
image_encoder_models = context.models.search_by_attrs(
name=image_encoder_model_name, base=BaseModelType.Any, type=ModelType.CLIPVision
)
@@ -159,7 +215,11 @@ def _get_image_encoder(self, context: InvocationContext, image_encoder_model_nam
)
installer = context._services.model_manager.install
- job = installer.heuristic_import(f"InvokeAI/{image_encoder_model_name}")
+ # Note: We hard-code the type to CLIPVision here because if the model contains both a CLIPVision and a
+ # CLIPText model, the probe may treat it as a CLIPText model.
+ job = installer.heuristic_import(
+ image_encoder_model_id, ModelRecordChanges(name=image_encoder_model_name, type=ModelType.CLIPVision)
+ )
installer.wait_for_job(job, timeout=600) # Wait for up to 10 minutes
image_encoder_models = context.models.search_by_attrs(
name=image_encoder_model_name, base=BaseModelType.Any, type=ModelType.CLIPVision
diff --git a/invokeai/app/invocations/latent_noise.py b/invokeai/app/invocations/latent_noise.py
new file mode 100644
index 00000000000..815effe972c
--- /dev/null
+++ b/invokeai/app/invocations/latent_noise.py
@@ -0,0 +1,136 @@
+from typing import Literal
+
+import torch
+
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.backend.util.devices import TorchDevice
+
+LatentNoiseType = Literal["SD", "FLUX", "FLUX.2", "SD3", "CogView4", "Z-Image", "Anima"]
+
+
+def validate_noise_dimensions(noise_type: LatentNoiseType, width: int, height: int) -> None:
+ multiple_of = 8
+ if noise_type in ("FLUX", "FLUX.2", "SD3", "Z-Image"):
+ multiple_of = 16
+ elif noise_type == "CogView4":
+ multiple_of = 32
+
+ if width % multiple_of != 0 or height % multiple_of != 0:
+ raise ValueError(f"{noise_type} noise width and height must be a multiple of {multiple_of}")
+
+
+def get_expected_noise_shape(
+ noise_type: LatentNoiseType,
+ width: int,
+ height: int,
+) -> tuple[int, ...]:
+ validate_noise_dimensions(noise_type, width, height)
+
+ if noise_type == "SD":
+ return (1, 4, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "FLUX":
+ return (1, 16, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "FLUX.2":
+ return (1, 32, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "SD3":
+ return (1, 16, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "CogView4":
+ return (1, 16, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "Z-Image":
+ return (1, 16, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ if noise_type == "Anima":
+ return (1, 16, 1, height // LATENT_SCALE_FACTOR, width // LATENT_SCALE_FACTOR)
+ raise ValueError(f"Unsupported noise type: {noise_type}")
+
+
+def validate_noise_tensor_shape(noise: torch.Tensor, noise_type: LatentNoiseType, width: int, height: int) -> None:
+ expected_shape = get_expected_noise_shape(noise_type, width, height)
+ if tuple(noise.shape) != expected_shape:
+ raise ValueError(f"Expected noise with shape {expected_shape}, got {tuple(noise.shape)}")
+
+
+def generate_noise_tensor(
+ noise_type: LatentNoiseType,
+ width: int,
+ height: int,
+ seed: int,
+ device: torch.device,
+ dtype: torch.dtype,
+ use_cpu: bool = True,
+) -> torch.Tensor:
+ validate_noise_dimensions(noise_type, width, height)
+ rand_device = "cpu" if use_cpu else device.type
+ rand_dtype = TorchDevice.choose_torch_dtype(device=device)
+
+ if noise_type == "SD":
+ return torch.randn(
+ 1,
+ 4,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ dtype=rand_dtype,
+ device=rand_device,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "FLUX":
+ return torch.randn(
+ 1,
+ 16,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "FLUX.2":
+ return torch.randn(
+ 1,
+ 32,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "SD3":
+ return torch.randn(
+ 1,
+ 16,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "CogView4":
+ return torch.randn(
+ 1,
+ 16,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "Z-Image":
+ return torch.randn(
+ 1,
+ 16,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=torch.float32,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ if noise_type == "Anima":
+ return torch.randn(
+ 1,
+ 16,
+ 1,
+ height // LATENT_SCALE_FACTOR,
+ width // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=torch.float32,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to("cpu")
+ raise ValueError(f"Unsupported noise type: {noise_type}")
diff --git a/invokeai/app/invocations/latents_to_image.py b/invokeai/app/invocations/latents_to_image.py
index 202e8bfa1bc..608485a078b 100644
--- a/invokeai/app/invocations/latents_to_image.py
+++ b/invokeai/app/invocations/latents_to_image.py
@@ -1,17 +1,12 @@
+from contextlib import nullcontext
+
import torch
from diffusers.image_processor import VaeImageProcessor
-from diffusers.models.attention_processor import (
- AttnProcessor2_0,
- LoRAAttnProcessor2_0,
- LoRAXFormersAttnProcessor,
- XFormersAttnProcessor,
-)
from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
from diffusers.models.autoencoders.autoencoder_tiny import AutoencoderTiny
-from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel
from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
-from invokeai.app.invocations.constants import DEFAULT_PRECISION
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import (
FieldDescriptions,
Input,
@@ -23,16 +18,18 @@
from invokeai.app.invocations.model import VAEField
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.stable_diffusion import set_seamless
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.stable_diffusion.vae_tiling import patch_vae_tiling_params
from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_sd15_sdxl
@invocation(
"l2i",
- title="Latents to Image",
+ title="Latents to Image - SD1.5, SDXL",
tags=["latents", "image", "vae", "l2i"],
category="latents",
- version="1.2.2",
+ version="1.3.2",
)
class LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Generates an image from latents."""
@@ -46,51 +43,59 @@ class LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
input=Input.Connection,
)
tiled: bool = InputField(default=False, description=FieldDescriptions.tiled)
- fp32: bool = InputField(default=DEFAULT_PRECISION == torch.float32, description=FieldDescriptions.fp32)
+ # NOTE: tile_size = 0 is a special value. We use this rather than `int | None`, because the workflow UI does not
+ # offer a way to directly set None values.
+ tile_size: int = InputField(default=0, multiple_of=8, description=FieldDescriptions.vae_tile_size)
+ fp32: bool = InputField(default=False, description=FieldDescriptions.fp32)
@torch.no_grad()
def invoke(self, context: InvocationContext) -> ImageOutput:
latents = context.tensors.load(self.latents.latents_name)
+ use_tiling = self.tiled or context.config.get().force_tiled_decode
+
vae_info = context.models.load(self.vae.vae)
- assert isinstance(vae_info.model, (UNet2DConditionModel, AutoencoderKL, AutoencoderTiny))
- with set_seamless(vae_info.model, self.vae.seamless_axes), vae_info as vae:
- assert isinstance(vae, torch.nn.Module)
- latents = latents.to(vae.device)
+ assert isinstance(vae_info.model, (AutoencoderKL, AutoencoderTiny))
+ estimated_working_memory = estimate_vae_working_memory_sd15_sdxl(
+ operation="decode",
+ image_tensor=latents,
+ vae=vae_info.model,
+ tile_size=self.tile_size if use_tiling else None,
+ fp32=self.fp32,
+ )
+ with (
+ SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes),
+ vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae),
+ ):
+ context.util.signal_progress("Running VAE decoder")
+ assert isinstance(vae, (AutoencoderKL, AutoencoderTiny))
+ latents = latents.to(TorchDevice.choose_torch_device())
if self.fp32:
+ # FP32 mode: convert everything to float32 for maximum precision
vae.to(dtype=torch.float32)
-
- use_torch_2_0_or_xformers = hasattr(vae.decoder, "mid_block") and isinstance(
- vae.decoder.mid_block.attentions[0].processor,
- (
- AttnProcessor2_0,
- XFormersAttnProcessor,
- LoRAXFormersAttnProcessor,
- LoRAAttnProcessor2_0,
- ),
- )
- # if xformers or torch_2_0 is used attention block does not need
- # to be in float32 which can save lots of memory
- if use_torch_2_0_or_xformers:
- vae.post_quant_conv.to(latents.dtype)
- vae.decoder.conv_in.to(latents.dtype)
- vae.decoder.mid_block.to(latents.dtype)
- else:
- latents = latents.float()
-
+ latents = latents.float()
else:
vae.to(dtype=torch.float16)
latents = latents.half()
- if self.tiled or context.config.get().force_tiled_decode:
+ if use_tiling:
vae.enable_tiling()
else:
vae.disable_tiling()
+ tiling_context = nullcontext()
+ if self.tile_size > 0:
+ tiling_context = patch_vae_tiling_params(
+ vae,
+ tile_sample_min_size=self.tile_size,
+ tile_latent_min_size=self.tile_size // LATENT_SCALE_FACTOR,
+ tile_overlap_factor=0.25,
+ )
+
# clear memory as vae decode can request a lot
TorchDevice.empty_cache()
- with torch.inference_mode():
+ with torch.inference_mode(), tiling_context:
# copied from diffusers pipeline
latents = latents / vae.config.scaling_factor
image = vae.decode(latents, return_dict=False)[0]
diff --git a/invokeai/app/invocations/lineart.py b/invokeai/app/invocations/lineart.py
new file mode 100644
index 00000000000..3ffd51b5b68
--- /dev/null
+++ b/invokeai/app/invocations/lineart.py
@@ -0,0 +1,34 @@
+from builtins import bool
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.lineart import Generator, LineartEdgeDetector
+
+
+@invocation(
+ "lineart_edge_detection",
+ title="Lineart Edge Detection",
+ tags=["controlnet", "lineart"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class LineartEdgeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an edge map using the Lineart model."""
+
+ image: ImageField = InputField(description="The image to process")
+ coarse: bool = InputField(default=False, description="Whether to use coarse mode")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ model_url = LineartEdgeDetector.get_model_url(self.coarse)
+ loaded_model = context.models.load_remote_model(model_url, LineartEdgeDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, Generator)
+ detector = LineartEdgeDetector(model)
+ edge_map = detector.run(image=image)
+
+ image_dto = context.images.save(image=edge_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/lineart_anime.py b/invokeai/app/invocations/lineart_anime.py
new file mode 100644
index 00000000000..f07476491cb
--- /dev/null
+++ b/invokeai/app/invocations/lineart_anime.py
@@ -0,0 +1,31 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.lineart_anime import LineartAnimeEdgeDetector, UnetGenerator
+
+
+@invocation(
+ "lineart_anime_edge_detection",
+ title="Lineart Anime Edge Detection",
+ tags=["controlnet", "lineart"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class LineartAnimeEdgeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Geneartes an edge map using the Lineart model."""
+
+ image: ImageField = InputField(description="The image to process")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ model_url = LineartAnimeEdgeDetector.get_model_url()
+ loaded_model = context.models.load_remote_model(model_url, LineartAnimeEdgeDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, UnetGenerator)
+ detector = LineartAnimeEdgeDetector(model)
+ edge_map = detector.run(image=image)
+
+ image_dto = context.images.save(image=edge_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/llava_onevision_vllm.py b/invokeai/app/invocations/llava_onevision_vllm.py
new file mode 100644
index 00000000000..ff3b801d37e
--- /dev/null
+++ b/invokeai/app/invocations/llava_onevision_vllm.py
@@ -0,0 +1,76 @@
+from typing import Any
+
+import torch
+from PIL.Image import Image
+from pydantic import field_validator
+from transformers import AutoProcessor, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, UIComponent
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import StringOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline
+from invokeai.backend.model_manager.taxonomy import ModelType
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "llava_onevision_vllm",
+ title="LLaVA OneVision VLLM",
+ tags=["vllm"],
+ category="multimodal",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class LlavaOnevisionVllmInvocation(BaseInvocation):
+ """Run a LLaVA OneVision VLLM model."""
+
+ images: list[ImageField] | ImageField | None = InputField(default=None, max_length=3, description="Input image.")
+ prompt: str = InputField(
+ default="",
+ description="Input text prompt.",
+ ui_component=UIComponent.Textarea,
+ )
+ vllm_model: ModelIdentifierField = InputField(
+ title="LLaVA Model Type",
+ description=FieldDescriptions.vllm_model,
+ ui_model_type=ModelType.LlavaOnevision,
+ )
+
+ @field_validator("images", mode="before")
+ def listify_images(cls, v: Any) -> list:
+ if v is None:
+ return v
+ if not isinstance(v, list):
+ return [v]
+ return v
+
+ def _get_images(self, context: InvocationContext) -> list[Image]:
+ if self.images is None:
+ return []
+
+ image_fields = self.images if isinstance(self.images, list) else [self.images]
+ return [context.images.get_pil(image_field.image_name, "RGB") for image_field in image_fields]
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ images = self._get_images(context)
+ model_config = context.models.get_config(self.vllm_model)
+
+ with context.models.load(self.vllm_model).model_on_device() as (_, model):
+ assert isinstance(model, LlavaOnevisionForConditionalGeneration)
+
+ model_abs_path = context.models.get_absolute_path(model_config)
+ processor = AutoProcessor.from_pretrained(model_abs_path, local_files_only=True)
+ assert isinstance(processor, LlavaOnevisionProcessor)
+
+ model = LlavaOnevisionPipeline(model, processor)
+ output = model.run(
+ prompt=self.prompt,
+ images=images,
+ device=TorchDevice.choose_torch_device(),
+ dtype=TorchDevice.choose_torch_dtype(),
+ )
+
+ return StringOutput(value=output)
diff --git a/invokeai/app/invocations/load_custom_nodes.py b/invokeai/app/invocations/load_custom_nodes.py
new file mode 100644
index 00000000000..a3a8194a3b9
--- /dev/null
+++ b/invokeai/app/invocations/load_custom_nodes.py
@@ -0,0 +1,83 @@
+import logging
+import shutil
+import sys
+import traceback
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+
+def load_custom_nodes(custom_nodes_path: Path, logger: logging.Logger):
+ """
+ Loads all custom nodes from the custom_nodes_path directory.
+
+ If custom_nodes_path does not exist, it creates it.
+
+ It also copies the custom_nodes/README.md file to the custom_nodes_path directory. Because this file may change,
+ it is _always_ copied to the custom_nodes_path directory.
+
+ Then, it crawls the custom_nodes_path directory and imports all top-level directories as python modules.
+
+ If the directory does not contain an __init__.py file or starts with an `_` or `.`, it is skipped.
+ """
+
+ # create the custom nodes directory if it does not exist
+ custom_nodes_path.mkdir(parents=True, exist_ok=True)
+
+ # Copy the README file to the custom nodes directory
+ source_custom_nodes_readme_path = Path(__file__).parent / "custom_nodes/README.md"
+ target_custom_nodes_readme_path = Path(custom_nodes_path) / "README.md"
+
+ # copy our custom nodes README to the custom nodes directory
+ shutil.copy(source_custom_nodes_readme_path, target_custom_nodes_readme_path)
+
+ loaded_packs: list[str] = []
+ failed_packs: list[str] = []
+
+ # Import custom nodes, see https://docs.python.org/3/library/importlib.html#importing-programmatically
+ for d in custom_nodes_path.iterdir():
+ # skip files
+ if not d.is_dir():
+ continue
+
+ # skip hidden directories
+ if d.name.startswith("_") or d.name.startswith("."):
+ continue
+
+ # skip directories without an `__init__.py`
+ init = d / "__init__.py"
+ if not init.exists():
+ continue
+
+ module_name = init.parent.stem
+
+ # skip if already imported
+ if module_name in globals():
+ continue
+
+ # load the module
+ spec = spec_from_file_location(module_name, init.absolute())
+
+ if spec is None or spec.loader is None:
+ logger.warning(f"Could not load {init}")
+ continue
+
+ logger.info(f"Loading node pack {module_name}")
+
+ try:
+ module = module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+
+ loaded_packs.append(module_name)
+ except Exception:
+ failed_packs.append(module_name)
+ full_error = traceback.format_exc()
+ logger.error(f"Failed to load node pack {module_name} (may have partially loaded):\n{full_error}")
+
+ del init, module_name
+
+ loaded_count = len(loaded_packs)
+ if loaded_count > 0:
+ logger.info(
+ f"Loaded {loaded_count} node pack{'s' if loaded_count != 1 else ''} from {custom_nodes_path}: {', '.join(loaded_packs)}"
+ )
diff --git a/invokeai/app/invocations/logic.py b/invokeai/app/invocations/logic.py
new file mode 100644
index 00000000000..7cc98afbbcf
--- /dev/null
+++ b/invokeai/app/invocations/logic.py
@@ -0,0 +1,34 @@
+from typing import Any, Optional
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.fields import InputField, OutputField, UIType
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("if_output")
+class IfInvocationOutput(BaseInvocationOutput):
+ value: Optional[Any] = OutputField(
+ default=None, description="The selected value", title="Output", ui_type=UIType.Any
+ )
+
+
+@invocation("if", title="If", tags=["logic", "conditional"], category="math", version="1.0.0")
+class IfInvocation(BaseInvocation):
+ """Selects between two optional inputs based on a boolean condition."""
+
+ condition: bool = InputField(default=False, description="The condition used to select an input", title="Condition")
+ true_input: Optional[Any] = InputField(
+ default=None,
+ description="Selected when the condition is true",
+ title="True Input",
+ ui_type=UIType.Any,
+ )
+ false_input: Optional[Any] = InputField(
+ default=None,
+ description="Selected when the condition is false",
+ title="False Input",
+ ui_type=UIType.Any,
+ )
+
+ def invoke(self, context: InvocationContext) -> IfInvocationOutput:
+ return IfInvocationOutput(value=self.true_input if self.condition else self.false_input)
diff --git a/invokeai/app/invocations/mask.py b/invokeai/app/invocations/mask.py
index 6f54660847a..49749f43b64 100644
--- a/invokeai/app/invocations/mask.py
+++ b/invokeai/app/invocations/mask.py
@@ -1,16 +1,30 @@
import numpy as np
import torch
+from PIL import Image
-from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, InvocationContext, invocation
-from invokeai.app.invocations.fields import ImageField, InputField, TensorField, WithMetadata
-from invokeai.app.invocations.primitives import MaskOutput
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ InvocationContext,
+ invocation,
+)
+from invokeai.app.invocations.fields import (
+ BoundingBoxField,
+ ColorField,
+ ImageField,
+ InputField,
+ TensorField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.primitives import BoundingBoxOutput, ImageOutput, MaskOutput
+from invokeai.backend.image_util.util import pil_to_np
@invocation(
"rectangle_mask",
title="Create Rectangle Mask",
tags=["conditioning"],
- category="conditioning",
+ category="mask",
version="1.0.1",
)
class RectangleMaskInvocation(BaseInvocation, WithMetadata):
@@ -41,9 +55,8 @@ def invoke(self, context: InvocationContext) -> MaskOutput:
"alpha_mask_to_tensor",
title="Alpha Mask to Tensor",
tags=["conditioning"],
- category="conditioning",
+ category="mask",
version="1.0.0",
- classification=Classification.Beta,
)
class AlphaMaskToTensorInvocation(BaseInvocation):
"""Convert a mask image to a tensor. Opaque regions are 1 and transparent regions are 0."""
@@ -52,7 +65,7 @@ class AlphaMaskToTensorInvocation(BaseInvocation):
invert: bool = InputField(default=False, description="Whether to invert the mask.")
def invoke(self, context: InvocationContext) -> MaskOutput:
- image = context.images.get_pil(self.image.image_name)
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
mask = torch.zeros((1, image.height, image.width), dtype=torch.bool)
if self.invert:
mask[0] = torch.tensor(np.array(image)[:, :, 3] == 0, dtype=torch.bool)
@@ -70,9 +83,8 @@ def invoke(self, context: InvocationContext) -> MaskOutput:
"invert_tensor_mask",
title="Invert Tensor Mask",
tags=["conditioning"],
- category="conditioning",
- version="1.0.0",
- classification=Classification.Beta,
+ category="mask",
+ version="1.1.0",
)
class InvertTensorMaskInvocation(BaseInvocation):
"""Inverts a tensor mask."""
@@ -81,6 +93,15 @@ class InvertTensorMaskInvocation(BaseInvocation):
def invoke(self, context: InvocationContext) -> MaskOutput:
mask = context.tensors.load(self.mask.tensor_name)
+
+ # Verify dtype and shape.
+ assert mask.dtype == torch.bool
+ assert mask.dim() in [2, 3]
+
+ # Unsqueeze the channel dimension if it is missing. The MaskOutput type expects a single channel.
+ if mask.dim() == 2:
+ mask = mask.unsqueeze(0)
+
inverted = ~mask
return MaskOutput(
@@ -94,7 +115,7 @@ def invoke(self, context: InvocationContext) -> MaskOutput:
"image_mask_to_tensor",
title="Image Mask to Tensor",
tags=["conditioning"],
- category="conditioning",
+ category="mask",
version="1.0.0",
)
class ImageMaskToTensorInvocation(BaseInvocation, WithMetadata):
@@ -118,3 +139,128 @@ def invoke(self, context: InvocationContext) -> MaskOutput:
height=mask.shape[1],
width=mask.shape[2],
)
+
+
+@invocation(
+ "tensor_mask_to_image",
+ title="Tensor Mask to Image",
+ tags=["mask"],
+ category="mask",
+ version="1.1.0",
+)
+class MaskTensorToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Convert a mask tensor to an image."""
+
+ mask: TensorField = InputField(description="The mask tensor to convert.")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ mask = context.tensors.load(self.mask.tensor_name)
+
+ # Squeeze the channel dimension if it exists.
+ if mask.dim() == 3:
+ mask = mask.squeeze(0)
+
+ # Ensure that the mask is binary.
+ if mask.dtype != torch.bool:
+ mask = mask > 0.5
+ mask_np = (mask.float() * 255).byte().cpu().numpy()
+
+ mask_pil = Image.fromarray(mask_np, mode="L")
+ image_dto = context.images.save(image=mask_pil)
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "apply_tensor_mask_to_image",
+ title="Apply Tensor Mask to Image",
+ tags=["mask"],
+ category="mask",
+ version="1.0.0",
+)
+class ApplyMaskTensorToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Applies a tensor mask to an image.
+
+ The image is converted to RGBA and the mask is applied to the alpha channel."""
+
+ mask: TensorField = InputField(description="The mask tensor to apply.")
+ image: ImageField = InputField(description="The image to apply the mask to.")
+ invert: bool = InputField(default=False, description="Whether to invert the mask.")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, mode="RGBA")
+ mask = context.tensors.load(self.mask.tensor_name)
+
+ # Squeeze the channel dimension if it exists.
+ if mask.dim() == 3:
+ mask = mask.squeeze(0)
+
+ # Ensure that the mask is binary.
+ if mask.dtype != torch.bool:
+ mask = mask > 0.5
+ mask_np = (mask.float() * 255).byte().cpu().numpy().astype(np.uint8)
+
+ if self.invert:
+ mask_np = 255 - mask_np
+
+ # Apply the mask only to the alpha channel where the original alpha is non-zero. This preserves the original
+ # image's transparency - else the transparent regions would end up as opaque black.
+
+ # Separate the image into R, G, B, and A channels
+ image_np = pil_to_np(image)
+ r, g, b, a = np.split(image_np, 4, axis=-1)
+
+ # Apply the mask to the alpha channel
+ new_alpha = np.where(a.squeeze() > 0, mask_np, a.squeeze())
+
+ # Stack the RGB channels with the modified alpha
+ masked_image_np = np.dstack([r.squeeze(), g.squeeze(), b.squeeze(), new_alpha])
+
+ # Convert back to an image (RGBA)
+ masked_image = Image.fromarray(masked_image_np.astype(np.uint8), "RGBA")
+ image_dto = context.images.save(image=masked_image)
+
+ return ImageOutput.build(image_dto)
+
+
+WHITE = ColorField(r=255, g=255, b=255, a=255)
+
+
+@invocation(
+ "get_image_mask_bounding_box",
+ title="Get Image Mask Bounding Box",
+ tags=["mask"],
+ category="mask",
+ version="1.0.0",
+)
+class GetMaskBoundingBoxInvocation(BaseInvocation):
+ """Gets the bounding box of the given mask image."""
+
+ mask: ImageField = InputField(description="The mask to crop.")
+ margin: int = InputField(default=0, description="Margin to add to the bounding box.")
+ mask_color: ColorField = InputField(default=WHITE, description="Color of the mask in the image.")
+
+ def invoke(self, context: InvocationContext) -> BoundingBoxOutput:
+ mask = context.images.get_pil(self.mask.image_name, mode="RGBA")
+ mask_np = np.array(mask)
+
+ # Convert mask_color to RGBA tuple
+ mask_color_rgb = self.mask_color.tuple()
+
+ # Find the bounding box of the mask color
+ y, x = np.where(np.all(mask_np == mask_color_rgb, axis=-1))
+
+ if len(x) == 0 or len(y) == 0:
+ # No pixels found with the given color
+ return BoundingBoxOutput(bounding_box=BoundingBoxField(x_min=0, y_min=0, x_max=0, y_max=0))
+
+ left, upper, right, lower = x.min(), y.min(), x.max(), y.max()
+
+ # Add the margin
+ left = max(0, left - self.margin)
+ upper = max(0, upper - self.margin)
+ right = min(mask_np.shape[1], right + self.margin)
+ lower = min(mask_np.shape[0], lower + self.margin)
+
+ bounding_box = BoundingBoxField(x_min=left, y_min=upper, x_max=right, y_max=lower)
+
+ return BoundingBoxOutput(bounding_box=bounding_box)
diff --git a/invokeai/app/invocations/math.py b/invokeai/app/invocations/math.py
index dad000d411e..5d3988031ba 100644
--- a/invokeai/app/invocations/math.py
+++ b/invokeai/app/invocations/math.py
@@ -5,12 +5,11 @@
import numpy as np
from pydantic import ValidationInfo, field_validator
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
from invokeai.app.invocations.fields import FieldDescriptions, InputField
from invokeai.app.invocations.primitives import FloatOutput, IntegerOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import BaseInvocation, invocation
-
@invocation("add", title="Add Integers", tags=["math", "add"], category="math", version="1.0.1")
class AddInvocation(BaseInvocation):
diff --git a/invokeai/app/invocations/mediapipe_face.py b/invokeai/app/invocations/mediapipe_face.py
new file mode 100644
index 00000000000..e81326463ce
--- /dev/null
+++ b/invokeai/app/invocations/mediapipe_face.py
@@ -0,0 +1,26 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.mediapipe_face import detect_faces
+
+
+@invocation(
+ "mediapipe_face_detection",
+ title="MediaPipe Face Detection",
+ tags=["controlnet", "face"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class MediaPipeFaceDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Detects faces using MediaPipe."""
+
+ image: ImageField = InputField(description="The image to process")
+ max_faces: int = InputField(default=1, ge=1, description="Maximum number of faces to detect")
+ min_confidence: float = InputField(default=0.5, ge=0, le=1, description="Minimum confidence for face detection")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ detected_faces = detect_faces(image=image, max_faces=self.max_faces, min_confidence=self.min_confidence)
+ image_dto = context.images.save(image=detected_faces)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/metadata.py b/invokeai/app/invocations/metadata.py
index 9c7264a9bbb..da24d8802bb 100644
--- a/invokeai/app/invocations/metadata.py
+++ b/invokeai/app/invocations/metadata.py
@@ -2,7 +2,13 @@
from pydantic import BaseModel, ConfigDict, Field
-from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
from invokeai.app.invocations.fields import (
FieldDescriptions,
ImageField,
@@ -12,10 +18,10 @@
UIType,
)
from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import StringOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES
-
-from ...version import __version__
+from invokeai.version.invokeai_version import __version__
class MetadataItemField(BaseModel):
@@ -35,8 +41,10 @@ class IPAdapterMetadataField(BaseModel):
image: ImageField = Field(description="The IP-Adapter image prompt.")
ip_adapter_model: ModelIdentifierField = Field(description="The IP-Adapter model.")
- clip_vision_model: Literal["ViT-H", "ViT-G"] = Field(description="The CLIP Vision model")
- method: Literal["full", "style", "composition"] = Field(description="Method to apply IP Weights with")
+ clip_vision_model: Literal["ViT-L", "ViT-H", "ViT-G"] = Field(description="The CLIP Vision model")
+ method: Literal["full", "style", "composition", "style_strong", "style_precise"] = Field(
+ description="Method to apply IP Weights with"
+ )
weight: Union[float, list[float]] = Field(description="The weight given to the IP-Adapter")
begin_step_percent: float = Field(description="When the IP-Adapter is first applied (% of total steps)")
end_step_percent: float = Field(description="When the IP-Adapter is last applied (% of total steps)")
@@ -130,13 +138,55 @@ def invoke(self, context: InvocationContext) -> MetadataOutput:
GENERATION_MODES = Literal[
- "txt2img", "img2img", "inpaint", "outpaint", "sdxl_txt2img", "sdxl_img2img", "sdxl_inpaint", "sdxl_outpaint"
+ "txt2img",
+ "img2img",
+ "inpaint",
+ "outpaint",
+ "sdxl_txt2img",
+ "sdxl_img2img",
+ "sdxl_inpaint",
+ "sdxl_outpaint",
+ "flux_txt2img",
+ "flux_img2img",
+ "flux_inpaint",
+ "flux_outpaint",
+ "flux2_txt2img",
+ "flux2_img2img",
+ "flux2_inpaint",
+ "flux2_outpaint",
+ "sd3_txt2img",
+ "sd3_img2img",
+ "sd3_inpaint",
+ "sd3_outpaint",
+ "cogview4_txt2img",
+ "cogview4_img2img",
+ "cogview4_inpaint",
+ "cogview4_outpaint",
+ "z_image_txt2img",
+ "z_image_img2img",
+ "z_image_inpaint",
+ "z_image_outpaint",
+ "qwen_image_txt2img",
+ "qwen_image_img2img",
+ "qwen_image_inpaint",
+ "qwen_image_outpaint",
+ "anima_txt2img",
+ "anima_img2img",
+ "anima_inpaint",
+ "anima_outpaint",
]
-@invocation("core_metadata", title="Core Metadata", tags=["metadata"], category="metadata", version="2.0.0")
+@invocation(
+ "core_metadata",
+ title="Core Metadata",
+ tags=["metadata"],
+ category="metadata",
+ version="2.1.0",
+ classification=Classification.Internal,
+)
class CoreMetadataInvocation(BaseInvocation):
- """Collects core generation metadata into a MetadataField"""
+ """Used internally by Invoke to collect metadata for generations."""
generation_mode: Optional[GENERATION_MODES] = InputField(
default=None,
@@ -183,6 +233,10 @@ class CoreMetadataInvocation(BaseInvocation):
default=None,
description="The VAE used for decoding, if the main model's default was not used",
)
+ qwen3_encoder: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="The Qwen3 text encoder model used for Z-Image inference",
+ )
# High resolution fix metadata.
hrf_enabled: Optional[bool] = InputField(
@@ -248,3 +302,34 @@ def invoke(self, context: InvocationContext) -> MetadataOutput:
return MetadataOutput(metadata=MetadataField.model_validate(as_dict))
model_config = ConfigDict(extra="allow")
+
+
+@invocation(
+ "metadata_field_extractor",
+ title="Metadata Field Extractor",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Deprecated,
+)
+class MetadataFieldExtractorInvocation(BaseInvocation):
+ """Extracts the text value from an image's metadata given a key.
+ Raises an error if the image has no metadata or if the value is not a string (nesting not permitted)."""
+
+ image: ImageField = InputField(description="The image to extract metadata from")
+ key: str = InputField(description="The key in the image's metadata to extract the value from")
+
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ image_name = self.image.image_name
+
+ metadata = context.images.get_metadata(image_name=image_name)
+ if not metadata:
+ raise ValueError(f"No metadata found on image {image_name}")
+
+ try:
+ val = metadata.root[self.key]
+ if not isinstance(val, str):
+ raise ValueError(f"Metadata at key '{self.key}' must be a string")
+ return StringOutput(value=val)
+ except KeyError as e:
+ raise ValueError(f"No key '{self.key}' found in the metadata for {image_name}") from e
diff --git a/invokeai/app/invocations/metadata_linked.py b/invokeai/app/invocations/metadata_linked.py
new file mode 100644
index 00000000000..cd733fab648
--- /dev/null
+++ b/invokeai/app/invocations/metadata_linked.py
@@ -0,0 +1,1361 @@
+# Adopted from @skunworkxdark's metadata nodes (MIT License)
+# https://github.com/skunkworxdark/metadata-linked-nodes
+# Thanks to @skunworkxdark for the original implementation!
+
+import copy
+from typing import Any, Dict, Literal, Optional, TypeVar, Union
+
+from pydantic import model_validator
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.controlnet import ControlField, ControlNetInvocation
+from invokeai.app.invocations.denoise_latents import DenoiseLatentsInvocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ MetadataField,
+ OutputField,
+ UIType,
+ WithMetadata,
+)
+from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation
+from invokeai.app.invocations.ip_adapter import IPAdapterField, IPAdapterInvocation
+from invokeai.app.invocations.metadata import LoRAMetadataField, MetadataOutput
+from invokeai.app.invocations.model import (
+ CLIPField,
+ LoRAField,
+ LoRALoaderOutput,
+ ModelIdentifierField,
+ SDXLLoRALoaderOutput,
+ UNetField,
+ VAEField,
+ VAEOutput,
+)
+from invokeai.app.invocations.primitives import (
+ BooleanCollectionOutput,
+ BooleanOutput,
+ FloatCollectionOutput,
+ FloatOutput,
+ IntegerCollectionOutput,
+ IntegerOutput,
+ LatentsOutput,
+ StringCollectionOutput,
+ StringOutput,
+)
+from invokeai.app.invocations.scheduler import SchedulerOutput
+from invokeai.app.invocations.t2i_adapter import T2IAdapterField, T2IAdapterInvocation
+from invokeai.app.invocations.z_image_denoise import ZImageDenoiseInvocation
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
+from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES
+from invokeai.version import __version__
+
+CUSTOM_LABEL: str = "* CUSTOM LABEL *"
+
+CORE_LABELS = Literal[
+ f"{CUSTOM_LABEL}",
+ "positive_prompt",
+ "positive_style_prompt",
+ "negative_prompt",
+ "negative_style_prompt",
+ "width",
+ "height",
+ "seed",
+ "cfg_scale",
+ "cfg_rescale_multiplier",
+ "steps",
+ "scheduler",
+ "clip_skip",
+ "model",
+ "vae",
+ "seamless_x",
+ "seamless_y",
+ "guidance",
+ "cfg_scale_start_step",
+ "cfg_scale_end_step",
+]
+
+CORE_LABELS_STRING = Literal[
+ f"{CUSTOM_LABEL}",
+ "positive_prompt",
+ "positive_style_prompt",
+ "negative_prompt",
+ "negative_style_prompt",
+]
+
+CORE_LABELS_INTEGER = Literal[
+ f"{CUSTOM_LABEL}",
+ "width",
+ "height",
+ "seed",
+ "steps",
+ "clip_skip",
+ "cfg_scale_start_step",
+ "cfg_scale_end_step",
+]
+
+CORE_LABELS_FLOAT = Literal[
+ f"{CUSTOM_LABEL}",
+ "cfg_scale",
+ "cfg_rescale_multiplier",
+ "guidance",
+]
+
+CORE_LABELS_BOOL = Literal[
+ f"{CUSTOM_LABEL}",
+ "seamless_x",
+ "seamless_y",
+]
+
+CORE_LABELS_SCHEDULER = Literal[
+ f"{CUSTOM_LABEL}",
+ "scheduler",
+]
+
+CORE_LABELS_MODEL = Literal[
+ f"{CUSTOM_LABEL}",
+ "model",
+]
+
+CORE_LABELS_VAE = Literal[
+ f"{CUSTOM_LABEL}",
+ "vae",
+]
+
+T = TypeVar("T")
+
+
+def append_list(item_cls: type[T], new_item: T, items: Union[T, list[T], None] = None) -> list[T]:
+ """Combines any number of items or lists into a single list,
+ ensuring consistency in type.
+
+ Args:
+ item_cls: The expected type of elements in the list.
+ items: An existing list or single item of type `item_cls`.
+ new_items: Additional item(s) to append. (default=None)
+
+ Returns:
+ The updated list containing valid items.
+
+ Raises:
+ ValueError: If any item in the list or new_item is not of the expected type.
+ """
+
+ if not isinstance(new_item, item_cls):
+ raise ValueError(f"Invalid new_item type in: {new_item}, expected {item_cls}")
+
+ if items is None:
+ return [new_item]
+
+ result: list[T] = []
+
+ if isinstance(items, item_cls):
+ result.append(items)
+ elif isinstance(items, list) and all(isinstance(i, item_cls) for i in items):
+ result.extend(items)
+ else:
+ raise ValueError(f"Invalid items type in: {items}, expected {item_cls}")
+
+ result.append(new_item)
+ return result
+
+
+def validate_custom_label(
+ model: Union[
+ "MetadataItemLinkedInvocation",
+ "MetadataToStringInvocation",
+ "MetadataToIntegerInvocation",
+ "MetadataToFloatInvocation",
+ "MetadataToBoolInvocation",
+ "MetadataToSchedulerInvocation",
+ "MetadataToModelInvocation",
+ "MetadataToSDXLModelInvocation",
+ "MetadataToVAEInvocation",
+ ],
+):
+ if model.label == CUSTOM_LABEL:
+ if model.custom_label is None or model.custom_label.strip() == "":
+ raise ValueError("You must enter a Custom Label")
+ return model
+
+
+def extract_model_key(
+ metadata: dict[str, Any],
+ label: Union[str, None],
+ default_key: str,
+ model_type: ModelType,
+ context: InvocationContext,
+) -> str:
+ """
+ Extracts a model key from the metadata based on the given label.
+
+ Args:
+ metadata (dict): The metadata root dictionary.
+ label (str): The label to search for.
+ default_key (str): The default model key to return if not found.
+ model_type (ModelType): model_type to use in the search if a model name_is found in the metadata
+ context (object): The context object containing models.
+
+ Returns:
+ Model key
+ """
+
+ if label in metadata:
+ if "key" in metadata[label]:
+ if context.models.exists(metadata[label]["key"]):
+ return metadata[label]["key"]
+ if "name" in metadata[label]:
+ search_model = context.models.search_by_attrs(name=metadata[label]["name"], type=model_type)
+ if len(search_model) > 0:
+ return search_model[0].key
+ if "model_name" in metadata[label]:
+ search_model = context.models.search_by_attrs(name=metadata[label]["model_name"], type=model_type)
+ if len(search_model) > 0:
+ return search_model[0].key
+
+ return default_key
+
+
+def get_model(
+ model_key: str,
+ context: InvocationContext,
+) -> ModelIdentifierField:
+ """
+ Gets a model based upon a model_key
+
+ Args:
+ mode_key (str): The model key to get
+ context (object): The context object containing models.
+
+ Returns:
+ ModelIdentifierField
+ """
+ if not context.models.exists(model_key):
+ raise Exception(f"Unknown model: {model_key}")
+
+ x = context.models.get_config(model_key)
+ return ModelIdentifierField.from_config(x)
+
+
+@invocation(
+ "metadata_item_linked",
+ title="Metadata Item Linked",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.1",
+ classification=Classification.Beta,
+)
+class MetadataItemLinkedInvocation(BaseInvocation, WithMetadata):
+ """Used to Create/Add/Update a value into a metadata label"""
+
+ label: CORE_LABELS = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ value: Any = InputField(description=FieldDescriptions.metadata_item_value, ui_type=UIType.Any)
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> MetadataOutput:
+ k = self.custom_label if self.label == CUSTOM_LABEL else self.label
+ v = self.value.vae if isinstance(self.value, VAEField) else self.value
+
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ data.update({str(k): v})
+ data.update({"app_version": __version__})
+
+ return MetadataOutput(metadata=MetadataField.model_validate(data))
+
+
+@invocation(
+ "metadata_from_image",
+ title="Metadata From Image",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.1",
+ classification=Classification.Beta,
+)
+class MetadataFromImageInvocation(BaseInvocation):
+ """Used to create a core metadata item then Add/Update it to the provided metadata"""
+
+ image: ImageField = InputField(description=FieldDescriptions.image)
+
+ def invoke(self, context: InvocationContext) -> MetadataOutput:
+ data: Dict[str, Any] = {}
+ image_metadata = context.images.get_metadata(self.image.image_name)
+ if image_metadata is not None:
+ data.update(image_metadata.root)
+
+ return MetadataOutput(metadata=MetadataField.model_validate(data))
+
+
+@invocation(
+ "metadata_to_string",
+ title="Metadata To String",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToStringInvocation(BaseInvocation, WithMetadata):
+ """Extracts a string value of a label from metadata"""
+
+ label: CORE_LABELS_STRING = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: str = InputField(description="The default string to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return StringOutput(value=str(output))
+
+
+@invocation(
+ "metadata_to_integer",
+ title="Metadata To Integer",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToIntegerInvocation(BaseInvocation, WithMetadata):
+ """Extracts an integer value of a label from metadata"""
+
+ label: CORE_LABELS_INTEGER = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: int = InputField(description="The default integer to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> IntegerOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return IntegerOutput(value=int(output))
+
+
+@invocation(
+ "metadata_to_float",
+ title="Metadata To Float",
+ tags=["metadata"],
+ category="metadata",
+ version="1.1.0",
+ classification=Classification.Beta,
+)
+class MetadataToFloatInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Float value of a label from metadata"""
+
+ label: CORE_LABELS_FLOAT = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: float = InputField(description="The default float to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> FloatOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return FloatOutput(value=float(output))
+
+
+@invocation(
+ "metadata_to_bool",
+ title="Metadata To Bool",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToBoolInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Boolean value of a label from metadata"""
+
+ label: CORE_LABELS_BOOL = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: bool = InputField(description="The default bool to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> BooleanOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return BooleanOutput(value=bool(output))
+
+
+@invocation(
+ "metadata_to_scheduler",
+ title="Metadata To Scheduler",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.1",
+ classification=Classification.Beta,
+)
+class MetadataToSchedulerInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Scheduler value of a label from metadata"""
+
+ label: CORE_LABELS_SCHEDULER = InputField(
+ default="scheduler",
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description="The default scheduler to use if not found in the metadata",
+ ui_type=UIType.Scheduler,
+ )
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> SchedulerOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return SchedulerOutput(scheduler=output)
+
+
+@invocation_output("metadata_to_model_output")
+class MetadataToModelOutput(BaseInvocationOutput):
+ """String to main model output"""
+
+ model: ModelIdentifierField = OutputField(
+ description=FieldDescriptions.main_model,
+ title="Model",
+ )
+ name: str = OutputField(description="Model Name", title="Name")
+ unet: UNetField = OutputField(description=FieldDescriptions.unet, title="UNet")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+ clip: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP")
+
+
+@invocation_output("metadata_to_sdxl_model_output")
+class MetadataToSDXLModelOutput(BaseInvocationOutput):
+ """String to SDXL main model output"""
+
+ model: ModelIdentifierField = OutputField(
+ description=FieldDescriptions.main_model,
+ title="Model",
+ )
+ name: str = OutputField(description="Model Name", title="Name")
+ unet: UNetField = OutputField(description=FieldDescriptions.unet, title="UNet")
+ clip: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP 1")
+ clip2: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP 2")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "metadata_to_model",
+ title="Metadata To Model",
+ tags=["metadata"],
+ category="metadata",
+ version="1.3.0",
+ classification=Classification.Beta,
+)
+class MetadataToModelInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Model value of a label from metadata"""
+
+ label: CORE_LABELS_MODEL = InputField(
+ default="model",
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: ModelIdentifierField = InputField(
+ description="The default model to use if not found in the metadata", ui_model_type=ModelType.Main
+ )
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> MetadataToModelOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ label = self.custom_label if self.label == CUSTOM_LABEL else self.label
+
+ model_key = extract_model_key(data, label, self.default_value.key, ModelType.Main, context)
+ model = get_model(model_key, context)
+
+ return MetadataToModelOutput(
+ model=model,
+ name=f"{model.base}: {model.name}",
+ unet=UNetField(
+ unet=model.model_copy(update={"submodel_type": SubModelType.UNet}),
+ scheduler=model.model_copy(update={"submodel_type": SubModelType.Scheduler}),
+ loras=[],
+ ),
+ clip=CLIPField(
+ tokenizer=model.model_copy(update={"submodel_type": SubModelType.Tokenizer}),
+ text_encoder=model.model_copy(update={"submodel_type": SubModelType.TextEncoder}),
+ loras=[],
+ skipped_layers=0,
+ ),
+ vae=VAEField(
+ vae=model.model_copy(update={"submodel_type": SubModelType.VAE}),
+ ),
+ )
+
+
+@invocation(
+ "metadata_to_sdxl_model",
+ title="Metadata To SDXL Model",
+ tags=["metadata"],
+ category="metadata",
+ version="1.3.0",
+ classification=Classification.Beta,
+)
+class MetadataToSDXLModelInvocation(BaseInvocation, WithMetadata):
+ """Extracts a SDXL Model value of a label from metadata"""
+
+ label: CORE_LABELS_MODEL = InputField(
+ default="model",
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: ModelIdentifierField = InputField(
+ description="The default SDXL Model to use if not found in the metadata",
+ ui_model_type=ModelType.Main,
+ ui_model_base=BaseModelType.StableDiffusionXL,
+ )
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> MetadataToSDXLModelOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ label = self.custom_label if self.label == CUSTOM_LABEL else self.label
+
+ model_key = extract_model_key(data, label, self.default_value.key, ModelType.Main, context)
+ model = get_model(model_key, context)
+
+ return MetadataToSDXLModelOutput(
+ model=model,
+ name=f"{model.base}: {model.name}",
+ unet=UNetField(
+ unet=model.model_copy(update={"submodel_type": SubModelType.UNet}),
+ scheduler=model.model_copy(update={"submodel_type": SubModelType.Scheduler}),
+ loras=[],
+ ),
+ clip=CLIPField(
+ tokenizer=model.model_copy(update={"submodel_type": SubModelType.Tokenizer}),
+ text_encoder=model.model_copy(update={"submodel_type": SubModelType.TextEncoder}),
+ loras=[],
+ skipped_layers=0,
+ ),
+ clip2=CLIPField(
+ tokenizer=model.model_copy(update={"submodel_type": SubModelType.Tokenizer2}),
+ text_encoder=model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}),
+ loras=[],
+ skipped_layers=0,
+ ),
+ vae=VAEField(
+ vae=model.model_copy(update={"submodel_type": SubModelType.VAE}),
+ ),
+ )
+
+
+@invocation_output("latents_meta_output")
+class LatentsMetaOutput(LatentsOutput, MetadataOutput):
+ """Latents + metadata"""
+
+
+@invocation(
+ "denoise_latents_meta",
+ title=f"{DenoiseLatentsInvocation.UIConfig.title} + Metadata",
+ tags=["latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"],
+ category="metadata",
+ version="1.1.1",
+)
+class DenoiseLatentsMetaInvocation(DenoiseLatentsInvocation, WithMetadata):
+ def invoke(self, context: InvocationContext) -> LatentsMetaOutput:
+ def _to_json(obj: Union[Any, list[Any]]):
+ if not isinstance(obj, list):
+ obj = [obj]
+
+ return [
+ item.model_dump(exclude_none=True, exclude={"id", "type", "is_intermediate", "use_cache"})
+ for item in obj
+ ]
+
+ def _loras_to_json(obj: Union[Any, list[Any]]):
+ if not isinstance(obj, list):
+ obj = [obj]
+
+ output: list[dict[str, Any]] = []
+ for item in obj:
+ output.append(
+ LoRAMetadataField(
+ model=item.lora,
+ weight=item.weight,
+ ).model_dump(exclude_none=True, exclude={"id", "type", "is_intermediate", "use_cache"})
+ )
+ return output
+
+ obj = super().invoke(context)
+
+ md: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ md.update({"width": obj.width})
+ md.update({"height": obj.height})
+ md.update({"steps": self.steps})
+ md.update({"cfg_scale": self.cfg_scale})
+ md.update({"cfg_rescale_multiplier": self.cfg_rescale_multiplier})
+ md.update({"denoising_start": self.denoising_start})
+ md.update({"denoising_end": self.denoising_end})
+ md.update({"scheduler": self.scheduler})
+ md.update({"model": self.unet.unet})
+ if isinstance(self.control, ControlField) or (isinstance(self.control, list) and len(self.control) > 0):
+ md.update({"controlnets": _to_json(self.control)})
+ if isinstance(self.ip_adapter, IPAdapterField) or (
+ isinstance(self.ip_adapter, list) and len(self.ip_adapter) > 0
+ ):
+ md.update({"ipAdapters": _to_json(self.ip_adapter)})
+ if isinstance(self.t2i_adapter, T2IAdapterField) or (
+ isinstance(self.t2i_adapter, list) and len(self.t2i_adapter) > 0
+ ):
+ md.update({"t2iAdapters": _to_json(self.t2i_adapter)})
+ if len(self.unet.loras) > 0:
+ md.update({"loras": _loras_to_json(self.unet.loras)})
+ if self.noise is not None:
+ md.update({"seed": self.noise.seed})
+
+ params = obj.__dict__.copy()
+ del params["type"]
+
+ return LatentsMetaOutput(**params, metadata=MetadataField.model_validate(md))
+
+
+@invocation(
+ "flux_denoise_meta",
+ title=f"{FluxDenoiseInvocation.UIConfig.title} + Metadata",
+ tags=["flux", "latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"],
+ category="metadata",
+ version="1.0.1",
+)
+class FluxDenoiseLatentsMetaInvocation(FluxDenoiseInvocation, WithMetadata):
+ """Run denoising process with a FLUX transformer model + metadata."""
+
+ def invoke(self, context: InvocationContext) -> LatentsMetaOutput:
+ def _loras_to_json(obj: Union[Any, list[Any]]):
+ if not isinstance(obj, list):
+ obj = [obj]
+
+ output: list[dict[str, Any]] = []
+ for item in obj:
+ output.append(
+ LoRAMetadataField(
+ model=item.lora,
+ weight=item.weight,
+ ).model_dump(exclude_none=True, exclude={"id", "type", "is_intermediate", "use_cache"})
+ )
+ return output
+
+ obj = super().invoke(context)
+
+ md: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ md.update({"width": obj.width})
+ md.update({"height": obj.height})
+ md.update({"steps": self.num_steps})
+ md.update({"guidance": self.guidance})
+ md.update({"denoising_start": self.denoising_start})
+ md.update({"denoising_end": self.denoising_end})
+ md.update({"model": self.transformer.transformer})
+ md.update(
+ {
+ "seed": self.noise.seed
+ if self.noise is not None and self.noise.seed is not None and (self.latents is None or self.add_noise)
+ else self.seed
+ }
+ )
+ md.update({"cfg_scale": self.cfg_scale})
+ md.update({"cfg_scale_start_step": self.cfg_scale_start_step})
+ md.update({"cfg_scale_end_step": self.cfg_scale_end_step})
+ if len(self.transformer.loras) > 0:
+ md.update({"loras": _loras_to_json(self.transformer.loras)})
+
+ params = obj.__dict__.copy()
+ del params["type"]
+
+ return LatentsMetaOutput(**params, metadata=MetadataField.model_validate(md))
+
+
+@invocation(
+ "z_image_denoise_meta",
+ title=f"{ZImageDenoiseInvocation.UIConfig.title} + Metadata",
+ tags=["z-image", "latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"],
+ category="metadata",
+ version="1.1.0",
+)
+class ZImageDenoiseMetaInvocation(ZImageDenoiseInvocation, WithMetadata):
+ """Run denoising process with a Z-Image transformer model + metadata."""
+
+ def invoke(self, context: InvocationContext) -> LatentsMetaOutput:
+ def _loras_to_json(obj: Union[Any, list[Any]]):
+ if not isinstance(obj, list):
+ obj = [obj]
+
+ output: list[dict[str, Any]] = []
+ for item in obj:
+ output.append(
+ LoRAMetadataField(
+ model=item.lora,
+ weight=item.weight,
+ ).model_dump(exclude_none=True, exclude={"id", "type", "is_intermediate", "use_cache"})
+ )
+ return output
+
+ obj = super().invoke(context)
+
+ md: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ md.update({"width": obj.width})
+ md.update({"height": obj.height})
+ md.update({"steps": self.steps})
+ md.update({"guidance": self.guidance_scale})
+ md.update({"denoising_start": self.denoising_start})
+ md.update({"denoising_end": self.denoising_end})
+ md.update({"scheduler": self.scheduler})
+ md.update({"model": self.transformer.transformer})
+ md.update(
+ {
+ "seed": self.noise.seed
+ if self.noise is not None and self.noise.seed is not None and (self.latents is None or self.add_noise)
+ else self.seed
+ }
+ )
+ if len(self.transformer.loras) > 0:
+ md.update({"loras": _loras_to_json(self.transformer.loras)})
+
+ params = obj.__dict__.copy()
+ del params["type"]
+
+ return LatentsMetaOutput(**params, metadata=MetadataField.model_validate(md))
+
+
+@invocation(
+ "metadata_to_vae",
+ title="Metadata To VAE",
+ tags=["metadata"],
+ category="metadata",
+ version="1.2.1",
+ classification=Classification.Beta,
+)
+class MetadataToVAEInvocation(BaseInvocation, WithMetadata):
+ """Extracts a VAE value of a label from metadata"""
+
+ label: CORE_LABELS_VAE = InputField(
+ default="vae",
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: VAEField = InputField(
+ description="The default VAE to use if not found in the metadata",
+ )
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> VAEOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ label = self.custom_label if self.label == CUSTOM_LABEL else self.label
+
+ model_key = extract_model_key(data, label, self.default_value.vae.key, ModelType.VAE, context)
+ model = get_model(model_key, context)
+ model.submodel_type = SubModelType.VAE
+
+ return VAEOutput(vae=VAEField(vae=model))
+
+
+@invocation_output("metadata_to_lora_collection_output")
+class MetadataToLorasCollectionOutput(BaseInvocationOutput):
+ """Model loader output"""
+
+ lora: list[LoRAField] = OutputField(description="Collection of LoRA model and weights", title="LoRAs")
+
+
+@invocation(
+ "metadata_to_lora_collection",
+ title="Metadata To LoRA Collection",
+ tags=["metadata"],
+ category="metadata",
+ version="1.1.0",
+ classification=Classification.Beta,
+)
+class MetadataToLorasCollectionInvocation(BaseInvocation, WithMetadata):
+ """Extracts Lora(s) from metadata into a collection"""
+
+ custom_label: str = InputField(
+ default="loras",
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=[], description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+
+ def invoke(self, context: InvocationContext) -> MetadataToLorasCollectionOutput:
+ metadata = {} if self.metadata is None else self.metadata.root
+ key: str = self.custom_label.strip()
+ if not key:
+ key = "loras"
+
+ if key in metadata:
+ loras = metadata[key]
+ else:
+ loras = []
+
+ input_loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ output = MetadataToLorasCollectionOutput(lora=[])
+ added_loras: list[str] = []
+
+ for lora in input_loras:
+ assert lora is LoRAField
+ if lora.lora.key in added_loras:
+ continue
+ output.lora.append(lora)
+ added_loras.append(lora.lora.key)
+
+ for lora in loras:
+ model_key = extract_model_key(lora, "model", "", ModelType.LoRA, context)
+ if not model_key:
+ model_key = extract_model_key(lora, "lora", "", ModelType.LoRA, context)
+ if model_key:
+ model = get_model(model_key, context)
+ weight = float(lora["weight"])
+ if model.key in added_loras:
+ continue
+ output.lora.append(LoRAField(lora=model, weight=weight))
+
+ return output
+
+
+@invocation(
+ "metadata_to_loras",
+ title="Metadata To LoRAs",
+ tags=["metadata"],
+ category="metadata",
+ version="1.1.1",
+ classification=Classification.Beta,
+)
+class MetadataToLorasInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Loras value of a label from metadata"""
+
+ unet: Optional[UNetField] = InputField(
+ default=None,
+ description=FieldDescriptions.unet,
+ input=Input.Connection,
+ title="UNet",
+ )
+ clip: Optional[CLIPField] = InputField(
+ default=None,
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ title="CLIP",
+ )
+
+ def invoke(self, context: InvocationContext) -> LoRALoaderOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ key = "loras"
+ if key in data:
+ loras = data[key]
+ else:
+ loras = []
+
+ output = LoRALoaderOutput()
+
+ if self.unet is not None:
+ output.unet = copy.deepcopy(self.unet)
+
+ if self.clip is not None:
+ output.clip = copy.deepcopy(self.clip)
+
+ for lora in loras:
+ model_key = extract_model_key(lora, "model", "", ModelType.LoRA, context)
+ if model_key != "":
+ model = get_model(model_key, context)
+ weight = float(lora["weight"])
+
+ if output.unet is not None:
+ if any(lora.lora.key == model_key for lora in output.unet.loras):
+ context.logger.info(f'LoRA "{model_key}" already applied to unet')
+ else:
+ output.unet.loras.append(
+ LoRAField(
+ lora=model,
+ weight=weight,
+ )
+ )
+
+ if output.clip is not None:
+ if any(lora.lora.key == model_key for lora in output.clip.loras):
+ context.logger.info(f'LoRA "{model_key}" already applied to clip')
+ else:
+ output.clip.loras.append(
+ LoRAField(
+ lora=model,
+ weight=weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "metadata_to_sdlx_loras",
+ title="Metadata To SDXL LoRAs",
+ tags=["metadata"],
+ category="metadata",
+ version="1.1.1",
+ classification=Classification.Beta,
+)
+class MetadataToSDXLLorasInvocation(BaseInvocation, WithMetadata):
+ """Extracts a SDXL Loras value of a label from metadata"""
+
+ unet: Optional[UNetField] = InputField(
+ default=None,
+ description=FieldDescriptions.unet,
+ input=Input.Connection,
+ title="UNet",
+ )
+ clip: Optional[CLIPField] = InputField(
+ default=None,
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ title="CLIP 1",
+ )
+ clip2: Optional[CLIPField] = InputField(
+ default=None,
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ title="CLIP 2",
+ )
+
+ def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ key = "loras"
+ if key in data:
+ loras = data[key]
+ else:
+ loras = []
+
+ output = SDXLLoRALoaderOutput()
+
+ if self.unet is not None:
+ output.unet = copy.deepcopy(self.unet)
+
+ if self.clip is not None:
+ output.clip = copy.deepcopy(self.clip)
+
+ if self.clip2 is not None:
+ output.clip2 = copy.deepcopy(self.clip2)
+
+ for lora in loras:
+ model_key = extract_model_key(lora, "model", "", ModelType.LoRA, context)
+ if model_key != "":
+ model = get_model(model_key, context)
+ weight = float(lora["weight"])
+
+ if output.unet is not None:
+ if any(lora.lora.key == model_key for lora in output.unet.loras):
+ context.logger.info(f'LoRA "{model_key}" already applied to unet')
+ else:
+ output.unet.loras.append(
+ LoRAField(
+ lora=model,
+ weight=weight,
+ )
+ )
+
+ if output.clip is not None:
+ if any(lora.lora.key == model_key for lora in output.clip.loras):
+ context.logger.info(f'LoRA "{model_key}" already applied to clip')
+ else:
+ output.clip.loras.append(
+ LoRAField(
+ lora=model,
+ weight=weight,
+ )
+ )
+
+ if output.clip2 is not None:
+ if any(lora.lora.key == model_key for lora in output.clip2.loras):
+ context.logger.info(f'LoRA "{model_key}" already applied to clip')
+ else:
+ output.clip2.loras.append(
+ LoRAField(
+ lora=model,
+ weight=weight,
+ )
+ )
+
+ return output
+
+
+@invocation_output("md_control_list_output")
+class MDControlListOutput(BaseInvocationOutput):
+ # Outputs
+ control_list: Optional[Union[ControlField, list[ControlField]]] = OutputField(
+ description=FieldDescriptions.control,
+ title="ControlNet-List",
+ )
+
+
+@invocation(
+ "metadata_to_controlnets",
+ title="Metadata To ControlNets",
+ tags=["metadata"],
+ category="metadata",
+ version="1.2.0",
+ classification=Classification.Beta,
+)
+class MetadataToControlnetsInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Controlnets value of a label from metadata"""
+
+ control_list: Optional[Union[ControlField, list[ControlField]]] = InputField(
+ default=None,
+ title="ControlNet-List",
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> MDControlListOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ key = "controlnets"
+ if key in data:
+ md_controls = data[key]
+ else:
+ md_controls = []
+
+ controls: Optional[Union[ControlField, list[ControlField]]]
+
+ if self.control_list is not None:
+ controls = self.control_list
+ else:
+ controls = []
+
+ for x in md_controls:
+ model_key = extract_model_key(x, "control_model", "", ModelType.ControlNet, context)
+ model = get_model(model_key, context)
+
+ cn = ControlNetInvocation(
+ image=x["image"],
+ control_model=model,
+ control_weight=x["control_weight"],
+ begin_step_percent=x["begin_step_percent"],
+ end_step_percent=x["end_step_percent"],
+ control_mode=x["control_mode"],
+ resize_mode=x["resize_mode"],
+ )
+ i = cn.invoke(context)
+
+ controls = append_list(ControlField, i.control, controls)
+
+ return MDControlListOutput(control_list=controls)
+
+
+@invocation_output("md_ip_adapter_list_output")
+class MDIPAdapterListOutput(BaseInvocationOutput):
+ # Outputs
+ ip_adapter_list: Optional[Union[IPAdapterField, list[IPAdapterField]]] = OutputField(
+ description=FieldDescriptions.ip_adapter, title="IP-Adapter-List"
+ )
+
+
+@invocation(
+ "metadata_to_ip_adapters",
+ title="Metadata To IP-Adapters",
+ tags=["metadata"],
+ category="metadata",
+ version="1.2.0",
+ classification=Classification.Beta,
+)
+class MetadataToIPAdaptersInvocation(BaseInvocation, WithMetadata):
+ """Extracts a IP-Adapters value of a label from metadata"""
+
+ ip_adapter_list: Optional[Union[IPAdapterField, list[IPAdapterField]]] = InputField(
+ description=FieldDescriptions.ip_adapter,
+ title="IP-Adapter-List",
+ default=None,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> MDIPAdapterListOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ key = "ipAdapters"
+ if key in data:
+ md_adapters = data[key]
+ else:
+ md_adapters = []
+
+ adapters: Optional[Union[IPAdapterField, list[IPAdapterField]]]
+
+ if self.ip_adapter_list is not None:
+ adapters = self.ip_adapter_list
+ else:
+ adapters = []
+
+ for x in md_adapters:
+ model_key = extract_model_key(x, "ip_adapter_model", "", ModelType.IPAdapter, context)
+ model = get_model(model_key, context)
+
+ ipa = IPAdapterInvocation(
+ image=x["image"],
+ ip_adapter_model=model,
+ weight=x["weight"],
+ begin_step_percent=x["begin_step_percent"],
+ end_step_percent=x["end_step_percent"],
+ )
+ i = ipa.invoke(context)
+
+ adapters = append_list(IPAdapterField, i.ip_adapter, adapters)
+
+ return MDIPAdapterListOutput(ip_adapter_list=adapters)
+
+
+@invocation_output("md_ip_adapters_output")
+class MDT2IAdapterListOutput(BaseInvocationOutput):
+ # Outputs
+ t2i_adapter_list: Optional[Union[T2IAdapterField, list[T2IAdapterField]]] = OutputField(
+ description=FieldDescriptions.t2i_adapter, title="T2I Adapter-List"
+ )
+
+
+@invocation(
+ "metadata_to_t2i_adapters",
+ title="Metadata To T2I-Adapters",
+ tags=["metadata"],
+ category="metadata",
+ version="1.2.0",
+ classification=Classification.Beta,
+)
+class MetadataToT2IAdaptersInvocation(BaseInvocation, WithMetadata):
+ """Extracts a T2I-Adapters value of a label from metadata"""
+
+ t2i_adapter_list: Optional[Union[T2IAdapterField, list[T2IAdapterField]]] = InputField(
+ description=FieldDescriptions.ip_adapter,
+ title="T2I-Adapter",
+ default=None,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> MDT2IAdapterListOutput:
+ data = {} if self.metadata is None else self.metadata.root
+ key = "t2iAdapters"
+ if key in data:
+ md_adapters = data[key]
+ else:
+ md_adapters = []
+
+ adapters: Optional[Union[T2IAdapterField, list[T2IAdapterField]]]
+
+ if self.t2i_adapter_list is not None:
+ adapters = self.t2i_adapter_list
+ else:
+ adapters = []
+
+ for x in md_adapters:
+ model_key = extract_model_key(x, "t2i_adapter_model", "", ModelType.T2IAdapter, context)
+ model = get_model(model_key, context)
+
+ t2i = T2IAdapterInvocation(
+ image=x["image"],
+ t2i_adapter_model=model,
+ weight=x["weight"],
+ begin_step_percent=x["begin_step_percent"],
+ end_step_percent=x["end_step_percent"],
+ resize_mode=x["resize_mode"],
+ )
+ i = t2i.invoke(context)
+
+ adapters = append_list(T2IAdapterField, i.t2i_adapter, adapters)
+
+ return MDT2IAdapterListOutput(t2i_adapter_list=adapters)
+
+
+@invocation(
+ "metadata_to_string_collection",
+ title="Metadata To String Collection",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToStringCollectionInvocation(BaseInvocation, WithMetadata):
+ """Extracts a string collection value of a label from metadata"""
+
+ label: CORE_LABELS_STRING = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: list[str] = InputField(
+ description="The default string collection to use if not found in the metadata"
+ )
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> StringCollectionOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return StringCollectionOutput(collection=output)
+
+
+@invocation(
+ "metadata_to_integer_collection",
+ title="Metadata To Integer Collection",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToIntegerCollectionInvocation(BaseInvocation, WithMetadata):
+ """Extracts an integer value Collection of a label from metadata"""
+
+ label: CORE_LABELS_INTEGER = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: list[int] = InputField(description="The default integer to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> IntegerCollectionOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return IntegerCollectionOutput(collection=output)
+
+
+@invocation(
+ "metadata_to_float_collection",
+ title="Metadata To Float Collection",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToFloatCollectionInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Float value Collection of a label from metadata"""
+
+ label: CORE_LABELS_FLOAT = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: list[float] = InputField(description="The default float to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> FloatCollectionOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return FloatCollectionOutput(collection=output)
+
+
+@invocation(
+ "metadata_to_bool_collection",
+ title="Metadata To Bool Collection",
+ tags=["metadata"],
+ category="metadata",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class MetadataToBoolCollectionInvocation(BaseInvocation, WithMetadata):
+ """Extracts a Boolean value Collection of a label from metadata"""
+
+ label: CORE_LABELS_BOOL = InputField(
+ default=CUSTOM_LABEL,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ custom_label: Optional[str] = InputField(
+ default=None,
+ description=FieldDescriptions.metadata_item_label,
+ input=Input.Direct,
+ )
+ default_value: list[bool] = InputField(description="The default bool to use if not found in the metadata")
+
+ _validate_custom_label = model_validator(mode="after")(validate_custom_label)
+
+ def invoke(self, context: InvocationContext) -> BooleanCollectionOutput:
+ data: Dict[str, Any] = {} if self.metadata is None else self.metadata.root
+ output = data.get(str(self.custom_label if self.label == CUSTOM_LABEL else self.label), self.default_value)
+
+ return BooleanCollectionOutput(collection=output)
diff --git a/invokeai/app/invocations/mlsd.py b/invokeai/app/invocations/mlsd.py
new file mode 100644
index 00000000000..a2446876c88
--- /dev/null
+++ b/invokeai/app/invocations/mlsd.py
@@ -0,0 +1,39 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.mlsd import MLSDDetector
+from invokeai.backend.image_util.mlsd.models.mbv2_mlsd_large import MobileV2_MLSD_Large
+
+
+@invocation(
+ "mlsd_detection",
+ title="MLSD Detection",
+ tags=["controlnet", "mlsd", "edge"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class MLSDDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an line segment map using MLSD."""
+
+ image: ImageField = InputField(description="The image to process")
+ score_threshold: float = InputField(
+ default=0.1, ge=0, description="The threshold used to score points when determining line segments"
+ )
+ distance_threshold: float = InputField(
+ default=20.0,
+ ge=0,
+ description="Threshold for including a line segment - lines shorter than this distance will be discarded",
+ )
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ loaded_model = context.models.load_remote_model(MLSDDetector.get_model_url(), MLSDDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, MobileV2_MLSD_Large)
+ detector = MLSDDetector(model)
+ edge_map = detector.run(image, self.score_threshold, self.distance_threshold)
+
+ image_dto = context.images.save(image=edge_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/model.py b/invokeai/app/invocations/model.py
index 94a6136fcb9..0c96cdb1d9d 100644
--- a/invokeai/app/invocations/model.py
+++ b/invokeai/app/invocations/model.py
@@ -3,18 +3,17 @@
from pydantic import BaseModel, Field
-from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField, UIType
-from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.app.shared.models import FreeUConfig
-from invokeai.backend.model_manager.config import AnyModelConfig, BaseModelType, ModelType, SubModelType
-
-from .baseinvocation import (
+from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
BaseInvocationOutput,
- Classification,
invocation,
invocation_output,
)
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, Input, InputField, OutputField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.shared.models import FreeUConfig
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
class ModelIdentifierField(BaseModel):
@@ -23,8 +22,9 @@ class ModelIdentifierField(BaseModel):
name: str = Field(description="The model's name")
base: BaseModelType = Field(description="The model's base model type")
type: ModelType = Field(description="The model's type")
- submodel_type: Optional[SubModelType] = Field(
- description="The submodel to load, if this is a main model", default=None
+ submodel_type: SubModelType | None = Field(
+ description="The submodel to load, if this is a main model",
+ default=None,
)
@classmethod
@@ -61,11 +61,46 @@ class CLIPField(BaseModel):
loras: List[LoRAField] = Field(description="LoRAs to apply on model loading")
+class T5EncoderField(BaseModel):
+ tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel")
+ text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel")
+ loras: List[LoRAField] = Field(description="LoRAs to apply on model loading")
+
+
+class GlmEncoderField(BaseModel):
+ tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel")
+ text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel")
+
+
+class QwenVLEncoderField(BaseModel):
+ """Field for Qwen2.5-VL encoder used by Qwen Image Edit models."""
+
+ tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel")
+ text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel")
+
+
+class Qwen3EncoderField(BaseModel):
+ """Field for Qwen3 text encoder used by Z-Image models."""
+
+ tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel")
+ text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel")
+ loras: List[LoRAField] = Field(default_factory=list, description="LoRAs to apply on model loading")
+
+
class VAEField(BaseModel):
vae: ModelIdentifierField = Field(description="Info to load vae submodel")
seamless_axes: List[str] = Field(default_factory=list, description='Axes("x" and "y") to which apply seamless')
+class ControlLoRAField(LoRAField):
+ img: ImageField = Field(description="Image to use in structural conditioning")
+
+
+class TransformerField(BaseModel):
+ transformer: ModelIdentifierField = Field(description="Info to load Transformer submodel")
+ loras: List[LoRAField] = Field(description="LoRAs to apply on model loading")
+
+
@invocation_output("unet_output")
class UNetOutput(BaseInvocationOutput):
"""Base class for invocations that output a UNet field."""
@@ -103,11 +138,10 @@ class ModelIdentifierOutput(BaseInvocationOutput):
@invocation(
"model_identifier",
- title="Model identifier",
+ title="Any Model",
tags=["model"],
category="model",
- version="1.0.0",
- classification=Classification.Prototype,
+ version="1.0.1",
)
class ModelIdentifierInvocation(BaseInvocation):
"""Selects any model, outputting it its identifier. Be careful with this one! The identifier will be accepted as
@@ -125,15 +159,19 @@ def invoke(self, context: InvocationContext) -> ModelIdentifierOutput:
@invocation(
"main_model_loader",
- title="Main Model",
+ title="Main Model - SD1.5, SD2",
tags=["model"],
category="model",
- version="1.0.3",
+ version="1.0.4",
)
class MainModelLoaderInvocation(BaseInvocation):
"""Loads a main model, outputting its submodels."""
- model: ModelIdentifierField = InputField(description=FieldDescriptions.main_model, ui_type=UIType.MainModel)
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.main_model,
+ ui_model_base=[BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2],
+ ui_model_type=ModelType.Main,
+ )
# TODO: precision?
def invoke(self, context: InvocationContext) -> ModelLoaderOutput:
@@ -162,12 +200,15 @@ class LoRALoaderOutput(BaseInvocationOutput):
clip: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP")
-@invocation("lora_loader", title="LoRA", tags=["model"], category="model", version="1.0.3")
+@invocation("lora_loader", title="Apply LoRA - SD1.5", tags=["model"], category="model", version="1.0.4")
class LoRALoaderInvocation(BaseInvocation):
"""Apply selected lora to unet and text_encoder."""
lora: ModelIdentifierField = InputField(
- description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.StableDiffusion1,
+ ui_model_type=ModelType.LoRA,
)
weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
unet: Optional[UNetField] = InputField(
@@ -187,7 +228,7 @@ def invoke(self, context: InvocationContext) -> LoRALoaderOutput:
lora_key = self.lora.key
if not context.models.exists(lora_key):
- raise Exception(f"Unkown lora: {lora_key}!")
+ raise Exception(f"Unknown lora: {lora_key}!")
if self.unet is not None and any(lora.lora.key == lora_key for lora in self.unet.loras):
raise Exception(f'LoRA "{lora_key}" already applied to unet')
@@ -225,12 +266,14 @@ class LoRASelectorOutput(BaseInvocationOutput):
lora: LoRAField = OutputField(description="LoRA model and weight", title="LoRA")
-@invocation("lora_selector", title="LoRA Selector", tags=["model"], category="model", version="1.0.1")
+@invocation("lora_selector", title="Select LoRA", tags=["model"], category="model", version="1.0.3")
class LoRASelectorInvocation(BaseInvocation):
"""Selects a LoRA model and weight."""
lora: ModelIdentifierField = InputField(
- description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_type=ModelType.LoRA,
)
weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
@@ -238,12 +281,14 @@ def invoke(self, context: InvocationContext) -> LoRASelectorOutput:
return LoRASelectorOutput(lora=LoRAField(lora=self.lora, weight=self.weight))
-@invocation("lora_collection_loader", title="LoRA Collection Loader", tags=["model"], category="model", version="1.0.0")
+@invocation(
+ "lora_collection_loader", title="Apply LoRA Collection - SD1.5", tags=["model"], category="model", version="1.1.2"
+)
class LoRACollectionLoader(BaseInvocation):
"""Applies a collection of LoRAs to the provided UNet and CLIP models."""
- loras: LoRAField | list[LoRAField] = InputField(
- description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
)
unet: Optional[UNetField] = InputField(
default=None,
@@ -263,7 +308,14 @@ def invoke(self, context: InvocationContext) -> LoRALoaderOutput:
loras = self.loras if isinstance(self.loras, list) else [self.loras]
added_loras: list[str] = []
+ if self.unet is not None:
+ output.unet = self.unet.model_copy(deep=True)
+ if self.clip is not None:
+ output.clip = self.clip.model_copy(deep=True)
+
for lora in loras:
+ if lora is None:
+ continue
if lora.lora.key in added_loras:
continue
@@ -274,14 +326,10 @@ def invoke(self, context: InvocationContext) -> LoRALoaderOutput:
added_loras.append(lora.lora.key)
- if self.unet is not None:
- if output.unet is None:
- output.unet = self.unet.model_copy(deep=True)
+ if self.unet is not None and output.unet is not None:
output.unet.loras.append(lora)
- if self.clip is not None:
- if output.clip is None:
- output.clip = self.clip.model_copy(deep=True)
+ if self.clip is not None and output.clip is not None:
output.clip.loras.append(lora)
return output
@@ -298,16 +346,19 @@ class SDXLLoRALoaderOutput(BaseInvocationOutput):
@invocation(
"sdxl_lora_loader",
- title="SDXL LoRA",
+ title="Apply LoRA - SDXL",
tags=["lora", "model"],
category="model",
- version="1.0.3",
+ version="1.0.5",
)
class SDXLLoRALoaderInvocation(BaseInvocation):
"""Apply selected lora to unet and text_encoder."""
lora: ModelIdentifierField = InputField(
- description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.StableDiffusionXL,
+ ui_model_type=ModelType.LoRA,
)
weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
unet: Optional[UNetField] = InputField(
@@ -378,16 +429,16 @@ def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput:
@invocation(
"sdxl_lora_collection_loader",
- title="SDXL LoRA Collection Loader",
+ title="Apply LoRA Collection - SDXL",
tags=["model"],
category="model",
- version="1.0.0",
+ version="1.1.2",
)
class SDXLLoRACollectionLoader(BaseInvocation):
"""Applies a collection of SDXL LoRAs to the provided UNet and CLIP models."""
- loras: LoRAField | list[LoRAField] = InputField(
- description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
)
unet: Optional[UNetField] = InputField(
default=None,
@@ -413,7 +464,18 @@ def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput:
loras = self.loras if isinstance(self.loras, list) else [self.loras]
added_loras: list[str] = []
+ if self.unet is not None:
+ output.unet = self.unet.model_copy(deep=True)
+
+ if self.clip is not None:
+ output.clip = self.clip.model_copy(deep=True)
+
+ if self.clip2 is not None:
+ output.clip2 = self.clip2.model_copy(deep=True)
+
for lora in loras:
+ if lora is None:
+ continue
if lora.lora.key in added_loras:
continue
@@ -424,37 +486,47 @@ def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput:
added_loras.append(lora.lora.key)
- if self.unet is not None:
- if output.unet is None:
- output.unet = self.unet.model_copy(deep=True)
+ if self.unet is not None and output.unet is not None:
output.unet.loras.append(lora)
- if self.clip is not None:
- if output.clip is None:
- output.clip = self.clip.model_copy(deep=True)
+ if self.clip is not None and output.clip is not None:
output.clip.loras.append(lora)
- if self.clip2 is not None:
- if output.clip2 is None:
- output.clip2 = self.clip2.model_copy(deep=True)
+ if self.clip2 is not None and output.clip2 is not None:
output.clip2.loras.append(lora)
return output
-@invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model", version="1.0.3")
+@invocation(
+ "vae_loader",
+ title="VAE Model - SD1.5, SD2, SDXL, SD3, FLUX",
+ tags=["vae", "model"],
+ category="model",
+ version="1.0.4",
+)
class VAELoaderInvocation(BaseInvocation):
"""Loads a VAE model, outputting a VaeLoaderOutput"""
vae_model: ModelIdentifierField = InputField(
- description=FieldDescriptions.vae_model, title="VAE", ui_type=UIType.VAEModel
+ description=FieldDescriptions.vae_model,
+ title="VAE",
+ ui_model_base=[
+ BaseModelType.StableDiffusion1,
+ BaseModelType.StableDiffusion2,
+ BaseModelType.StableDiffusionXL,
+ BaseModelType.StableDiffusion3,
+ BaseModelType.Flux,
+ BaseModelType.Flux2,
+ ],
+ ui_model_type=ModelType.VAE,
)
def invoke(self, context: InvocationContext) -> VAEOutput:
key = self.vae_model.key
if not context.models.exists(key):
- raise Exception(f"Unkown vae: {key}!")
+ raise Exception(f"Unknown vae: {key}!")
return VAEOutput(vae=VAEField(vae=self.vae_model))
@@ -469,10 +541,10 @@ class SeamlessModeOutput(BaseInvocationOutput):
@invocation(
"seamless",
- title="Seamless",
+ title="Apply Seamless - SD1.5, SDXL",
tags=["seamless", "model"],
category="model",
- version="1.0.1",
+ version="1.0.2",
)
class SeamlessModeInvocation(BaseInvocation):
"""Applies the seamless transformation to the Model UNet and VAE."""
@@ -512,7 +584,7 @@ def invoke(self, context: InvocationContext) -> SeamlessModeOutput:
return SeamlessModeOutput(unet=unet, vae=vae)
-@invocation("freeu", title="FreeU", tags=["freeu"], category="unet", version="1.0.1")
+@invocation("freeu", title="Apply FreeU - SD1.5, SDXL", tags=["freeu"], category="model", version="1.0.2")
class FreeUInvocation(BaseInvocation):
"""
Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):
diff --git a/invokeai/app/invocations/noise.py b/invokeai/app/invocations/noise.py
index 931e6391063..cfac3f112a9 100644
--- a/invokeai/app/invocations/noise.py
+++ b/invokeai/app/invocations/noise.py
@@ -1,62 +1,16 @@
-# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) & the InvokeAI Team
-
-
import torch
from pydantic import field_validator
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import FieldDescriptions, InputField, LatentsField, OutputField
+from invokeai.app.invocations.latent_noise import (
+ LatentNoiseType,
+ generate_noise_tensor,
+)
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.misc import SEED_MAX
-
-from ...backend.util.devices import TorchDevice
-from .baseinvocation import (
- BaseInvocation,
- BaseInvocationOutput,
- invocation,
- invocation_output,
-)
-
-"""
-Utilities
-"""
-
-
-def get_noise(
- width: int,
- height: int,
- device: torch.device,
- seed: int = 0,
- latent_channels: int = 4,
- downsampling_factor: int = 8,
- use_cpu: bool = True,
- perlin: float = 0.0,
-):
- """Generate noise for a given image size."""
- noise_device_type = "cpu" if use_cpu else device.type
-
- # limit noise to only the diffusion image channels, not the mask channels
- input_channels = min(latent_channels, 4)
- generator = torch.Generator(device=noise_device_type).manual_seed(seed)
-
- noise_tensor = torch.randn(
- [
- 1,
- input_channels,
- height // downsampling_factor,
- width // downsampling_factor,
- ],
- dtype=TorchDevice.choose_torch_dtype(device=device),
- device=noise_device_type,
- generator=generator,
- ).to("cpu")
-
- return noise_tensor
-
-
-"""
-Nodes
-"""
+from invokeai.backend.util.devices import TorchDevice
@invocation_output("noise_output")
@@ -71,20 +25,22 @@ class NoiseOutput(BaseInvocationOutput):
def build(cls, latents_name: str, latents: torch.Tensor, seed: int) -> "NoiseOutput":
return cls(
noise=LatentsField(latents_name=latents_name, seed=seed),
- width=latents.size()[3] * LATENT_SCALE_FACTOR,
- height=latents.size()[2] * LATENT_SCALE_FACTOR,
+ width=latents.shape[-1] * LATENT_SCALE_FACTOR,
+ height=latents.shape[-2] * LATENT_SCALE_FACTOR,
)
@invocation(
"noise",
- title="Noise",
+ title="Create Latent Noise",
tags=["latents", "noise"],
category="latents",
- version="1.0.2",
+ version="1.1.0",
)
class NoiseInvocation(BaseInvocation):
- """Generates latent noise."""
+ """Generates latent noise for supported denoiser architectures."""
+
+ noise_type: LatentNoiseType = InputField(default="SD", description="Architecture-specific noise type.")
seed: int = InputField(
default=0,
@@ -115,11 +71,13 @@ def modulo_seed(cls, v):
return v % (SEED_MAX + 1)
def invoke(self, context: InvocationContext) -> NoiseOutput:
- noise = get_noise(
+ noise = generate_noise_tensor(
+ noise_type=self.noise_type,
width=self.width,
height=self.height,
device=TorchDevice.choose_torch_device(),
seed=self.seed,
+ dtype=TorchDevice.choose_torch_dtype(),
use_cpu=self.use_cpu,
)
name = context.tensors.save(tensor=noise)
diff --git a/invokeai/app/invocations/normal_bae.py b/invokeai/app/invocations/normal_bae.py
new file mode 100644
index 00000000000..11599271500
--- /dev/null
+++ b/invokeai/app/invocations/normal_bae.py
@@ -0,0 +1,31 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.normal_bae import NormalMapDetector
+from invokeai.backend.image_util.normal_bae.nets.NNET import NNET
+
+
+@invocation(
+ "normal_map",
+ title="Normal Map",
+ tags=["controlnet", "normal"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class NormalMapInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates a normal map."""
+
+ image: ImageField = InputField(description="The image to process")
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ loaded_model = context.models.load_remote_model(NormalMapDetector.get_model_url(), NormalMapDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, NNET)
+ detector = NormalMapDetector(model)
+ normal_map = detector.run(image=image)
+
+ image_dto = context.images.save(image=normal_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/param_easing.py b/invokeai/app/invocations/param_easing.py
index 0e590f4e2bd..ed4318d95d1 100644
--- a/invokeai/app/invocations/param_easing.py
+++ b/invokeai/app/invocations/param_easing.py
@@ -1,50 +1,10 @@
-import io
-from typing import Literal, Optional
-
-import matplotlib.pyplot as plt
import numpy as np
-import PIL.Image
-from easing_functions import (
- BackEaseIn,
- BackEaseInOut,
- BackEaseOut,
- BounceEaseIn,
- BounceEaseInOut,
- BounceEaseOut,
- CircularEaseIn,
- CircularEaseInOut,
- CircularEaseOut,
- CubicEaseIn,
- CubicEaseInOut,
- CubicEaseOut,
- ElasticEaseIn,
- ElasticEaseInOut,
- ElasticEaseOut,
- ExponentialEaseIn,
- ExponentialEaseInOut,
- ExponentialEaseOut,
- LinearInOut,
- QuadEaseIn,
- QuadEaseInOut,
- QuadEaseOut,
- QuarticEaseIn,
- QuarticEaseInOut,
- QuarticEaseOut,
- QuinticEaseIn,
- QuinticEaseInOut,
- QuinticEaseOut,
- SineEaseIn,
- SineEaseInOut,
- SineEaseOut,
-)
-from matplotlib.ticker import MaxNLocator
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField
from invokeai.app.invocations.primitives import FloatCollectionOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField
-
@invocation(
"float_range",
@@ -66,191 +26,3 @@ class FloatLinearRangeInvocation(BaseInvocation):
def invoke(self, context: InvocationContext) -> FloatCollectionOutput:
param_list = list(np.linspace(self.start, self.stop, self.steps))
return FloatCollectionOutput(collection=param_list)
-
-
-EASING_FUNCTIONS_MAP = {
- "Linear": LinearInOut,
- "QuadIn": QuadEaseIn,
- "QuadOut": QuadEaseOut,
- "QuadInOut": QuadEaseInOut,
- "CubicIn": CubicEaseIn,
- "CubicOut": CubicEaseOut,
- "CubicInOut": CubicEaseInOut,
- "QuarticIn": QuarticEaseIn,
- "QuarticOut": QuarticEaseOut,
- "QuarticInOut": QuarticEaseInOut,
- "QuinticIn": QuinticEaseIn,
- "QuinticOut": QuinticEaseOut,
- "QuinticInOut": QuinticEaseInOut,
- "SineIn": SineEaseIn,
- "SineOut": SineEaseOut,
- "SineInOut": SineEaseInOut,
- "CircularIn": CircularEaseIn,
- "CircularOut": CircularEaseOut,
- "CircularInOut": CircularEaseInOut,
- "ExponentialIn": ExponentialEaseIn,
- "ExponentialOut": ExponentialEaseOut,
- "ExponentialInOut": ExponentialEaseInOut,
- "ElasticIn": ElasticEaseIn,
- "ElasticOut": ElasticEaseOut,
- "ElasticInOut": ElasticEaseInOut,
- "BackIn": BackEaseIn,
- "BackOut": BackEaseOut,
- "BackInOut": BackEaseInOut,
- "BounceIn": BounceEaseIn,
- "BounceOut": BounceEaseOut,
- "BounceInOut": BounceEaseInOut,
-}
-
-EASING_FUNCTION_KEYS = Literal[tuple(EASING_FUNCTIONS_MAP.keys())]
-
-
-# actually I think for now could just use CollectionOutput (which is list[Any]
-@invocation(
- "step_param_easing",
- title="Step Param Easing",
- tags=["step", "easing"],
- category="step",
- version="1.0.2",
-)
-class StepParamEasingInvocation(BaseInvocation):
- """Experimental per-step parameter easing for denoising steps"""
-
- easing: EASING_FUNCTION_KEYS = InputField(default="Linear", description="The easing function to use")
- num_steps: int = InputField(default=20, description="number of denoising steps")
- start_value: float = InputField(default=0.0, description="easing starting value")
- end_value: float = InputField(default=1.0, description="easing ending value")
- start_step_percent: float = InputField(default=0.0, description="fraction of steps at which to start easing")
- end_step_percent: float = InputField(default=1.0, description="fraction of steps after which to end easing")
- # if None, then start_value is used prior to easing start
- pre_start_value: Optional[float] = InputField(default=None, description="value before easing start")
- # if None, then end value is used prior to easing end
- post_end_value: Optional[float] = InputField(default=None, description="value after easing end")
- mirror: bool = InputField(default=False, description="include mirror of easing function")
- # FIXME: add alt_mirror option (alternative to default or mirror), or remove entirely
- # alt_mirror: bool = InputField(default=False, description="alternative mirroring by dual easing")
- show_easing_plot: bool = InputField(default=False, description="show easing plot")
-
- def invoke(self, context: InvocationContext) -> FloatCollectionOutput:
- log_diagnostics = False
- # convert from start_step_percent to nearest step <= (steps * start_step_percent)
- # start_step = int(np.floor(self.num_steps * self.start_step_percent))
- start_step = int(np.round(self.num_steps * self.start_step_percent))
- # convert from end_step_percent to nearest step >= (steps * end_step_percent)
- # end_step = int(np.ceil((self.num_steps - 1) * self.end_step_percent))
- end_step = int(np.round((self.num_steps - 1) * self.end_step_percent))
-
- # end_step = int(np.ceil(self.num_steps * self.end_step_percent))
- num_easing_steps = end_step - start_step + 1
-
- # num_presteps = max(start_step - 1, 0)
- num_presteps = start_step
- num_poststeps = self.num_steps - (num_presteps + num_easing_steps)
- prelist = list(num_presteps * [self.pre_start_value])
- postlist = list(num_poststeps * [self.post_end_value])
-
- if log_diagnostics:
- context.logger.debug("start_step: " + str(start_step))
- context.logger.debug("end_step: " + str(end_step))
- context.logger.debug("num_easing_steps: " + str(num_easing_steps))
- context.logger.debug("num_presteps: " + str(num_presteps))
- context.logger.debug("num_poststeps: " + str(num_poststeps))
- context.logger.debug("prelist size: " + str(len(prelist)))
- context.logger.debug("postlist size: " + str(len(postlist)))
- context.logger.debug("prelist: " + str(prelist))
- context.logger.debug("postlist: " + str(postlist))
-
- easing_class = EASING_FUNCTIONS_MAP[self.easing]
- if log_diagnostics:
- context.logger.debug("easing class: " + str(easing_class))
- easing_list = []
- if self.mirror: # "expected" mirroring
- # if number of steps is even, squeeze duration down to (number_of_steps)/2
- # and create reverse copy of list to append
- # if number of steps is odd, squeeze duration down to ceil(number_of_steps/2)
- # and create reverse copy of list[1:end-1]
- # but if even then number_of_steps/2 === ceil(number_of_steps/2), so can just use ceil always
-
- base_easing_duration = int(np.ceil(num_easing_steps / 2.0))
- if log_diagnostics:
- context.logger.debug("base easing duration: " + str(base_easing_duration))
- even_num_steps = num_easing_steps % 2 == 0 # even number of steps
- easing_function = easing_class(
- start=self.start_value,
- end=self.end_value,
- duration=base_easing_duration - 1,
- )
- base_easing_vals = []
- for step_index in range(base_easing_duration):
- easing_val = easing_function.ease(step_index)
- base_easing_vals.append(easing_val)
- if log_diagnostics:
- context.logger.debug("step_index: " + str(step_index) + ", easing_val: " + str(easing_val))
- if even_num_steps:
- mirror_easing_vals = list(reversed(base_easing_vals))
- else:
- mirror_easing_vals = list(reversed(base_easing_vals[0:-1]))
- if log_diagnostics:
- context.logger.debug("base easing vals: " + str(base_easing_vals))
- context.logger.debug("mirror easing vals: " + str(mirror_easing_vals))
- easing_list = base_easing_vals + mirror_easing_vals
-
- # FIXME: add alt_mirror option (alternative to default or mirror), or remove entirely
- # elif self.alt_mirror: # function mirroring (unintuitive behavior (at least to me))
- # # half_ease_duration = round(num_easing_steps - 1 / 2)
- # half_ease_duration = round((num_easing_steps - 1) / 2)
- # easing_function = easing_class(start=self.start_value,
- # end=self.end_value,
- # duration=half_ease_duration,
- # )
- #
- # mirror_function = easing_class(start=self.end_value,
- # end=self.start_value,
- # duration=half_ease_duration,
- # )
- # for step_index in range(num_easing_steps):
- # if step_index <= half_ease_duration:
- # step_val = easing_function.ease(step_index)
- # else:
- # step_val = mirror_function.ease(step_index - half_ease_duration)
- # easing_list.append(step_val)
- # if log_diagnostics: logger.debug(step_index, step_val)
- #
-
- else: # no mirroring (default)
- easing_function = easing_class(
- start=self.start_value,
- end=self.end_value,
- duration=num_easing_steps - 1,
- )
- for step_index in range(num_easing_steps):
- step_val = easing_function.ease(step_index)
- easing_list.append(step_val)
- if log_diagnostics:
- context.logger.debug("step_index: " + str(step_index) + ", easing_val: " + str(step_val))
-
- if log_diagnostics:
- context.logger.debug("prelist size: " + str(len(prelist)))
- context.logger.debug("easing_list size: " + str(len(easing_list)))
- context.logger.debug("postlist size: " + str(len(postlist)))
-
- param_list = prelist + easing_list + postlist
-
- if self.show_easing_plot:
- plt.figure()
- plt.xlabel("Step")
- plt.ylabel("Param Value")
- plt.title("Per-Step Values Based On Easing: " + self.easing)
- plt.bar(range(len(param_list)), param_list)
- # plt.plot(param_list)
- ax = plt.gca()
- ax.xaxis.set_major_locator(MaxNLocator(integer=True))
- buf = io.BytesIO()
- plt.savefig(buf, format="png")
- buf.seek(0)
- im = PIL.Image.open(buf)
- im.show()
- buf.close()
-
- # output array of size steps, each entry list[i] is param value for step i
- return FloatCollectionOutput(collection=param_list)
diff --git a/invokeai/app/invocations/pbr_maps.py b/invokeai/app/invocations/pbr_maps.py
new file mode 100644
index 00000000000..945c3cad598
--- /dev/null
+++ b/invokeai/app/invocations/pbr_maps.py
@@ -0,0 +1,61 @@
+import pathlib
+from typing import Literal
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.fields import ImageField, InputField, OutputField, WithBoard, WithMetadata
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.pbr_maps.architecture.pbr_rrdb_net import PBR_RRDB_Net
+from invokeai.backend.image_util.pbr_maps.pbr_maps import NORMAL_MAP_MODEL, OTHER_MAP_MODEL, PBRMapsGenerator
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation_output("pbr_maps-output")
+class PBRMapsOutput(BaseInvocationOutput):
+ normal_map: ImageField = OutputField(default=None, description="The generated normal map")
+ roughness_map: ImageField = OutputField(default=None, description="The generated roughness map")
+ displacement_map: ImageField = OutputField(default=None, description="The generated displacement map")
+
+
+@invocation(
+ "pbr_maps", title="PBR Maps", tags=["image", "material"], category="controlnet_preprocessors", version="1.0.0"
+)
+class PBRMapsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generate Normal, Displacement and Roughness Map from a given image"""
+
+ image: ImageField = InputField(description="Input image")
+ tile_size: int = InputField(default=512, description="Tile size")
+ border_mode: Literal["none", "seamless", "mirror", "replicate"] = InputField(
+ default="none", description="Border mode to apply to eliminate any artifacts or seams"
+ )
+
+ def invoke(self, context: InvocationContext) -> PBRMapsOutput:
+ image_pil = context.images.get_pil(self.image.image_name, mode="RGB")
+
+ def loader(model_path: pathlib.Path):
+ return PBRMapsGenerator.load_model(model_path, TorchDevice.choose_torch_device())
+
+ torch_device = TorchDevice.choose_torch_device()
+
+ with (
+ context.models.load_remote_model(NORMAL_MAP_MODEL, loader) as normal_map_model,
+ context.models.load_remote_model(OTHER_MAP_MODEL, loader) as other_map_model,
+ ):
+ assert isinstance(normal_map_model, PBR_RRDB_Net)
+ assert isinstance(other_map_model, PBR_RRDB_Net)
+ pbr_pipeline = PBRMapsGenerator(normal_map_model, other_map_model, torch_device)
+ normal_map, roughness_map, displacement_map = pbr_pipeline.generate_maps(
+ image_pil, self.tile_size, self.border_mode
+ )
+
+ normal_map = context.images.save(normal_map)
+ normal_map_field = ImageField(image_name=normal_map.image_name)
+
+ roughness_map = context.images.save(roughness_map)
+ roughness_map_field = ImageField(image_name=roughness_map.image_name)
+
+ displacement_map = context.images.save(displacement_map)
+ displacement_map_field = ImageField(image_name=displacement_map.image_name)
+
+ return PBRMapsOutput(
+ normal_map=normal_map_field, roughness_map=roughness_map_field, displacement_map=displacement_map_field
+ )
diff --git a/invokeai/app/invocations/pidi.py b/invokeai/app/invocations/pidi.py
new file mode 100644
index 00000000000..5d8cab04589
--- /dev/null
+++ b/invokeai/app/invocations/pidi.py
@@ -0,0 +1,33 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, WithBoard, WithMetadata
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.pidi import PIDINetDetector
+from invokeai.backend.image_util.pidi.model import PiDiNet
+
+
+@invocation(
+ "pidi_edge_detection",
+ title="PiDiNet Edge Detection",
+ tags=["controlnet", "edge"],
+ category="controlnet_preprocessors",
+ version="1.0.0",
+)
+class PiDiNetEdgeDetectionInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an edge map using PiDiNet."""
+
+ image: ImageField = InputField(description="The image to process")
+ quantize_edges: bool = InputField(default=False, description=FieldDescriptions.safe_mode)
+ scribble: bool = InputField(default=False, description=FieldDescriptions.scribble_mode)
+
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ image = context.images.get_pil(self.image.image_name, "RGB")
+ loaded_model = context.models.load_remote_model(PIDINetDetector.get_model_url(), PIDINetDetector.load_model)
+
+ with loaded_model as model:
+ assert isinstance(model, PiDiNet)
+ detector = PIDINetDetector(model)
+ edge_map = detector.run(image=image, quantize_edges=self.quantize_edges, scribble=self.scribble)
+
+ image_dto = context.images.save(image=edge_map)
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py
index 28f72fb377a..7ec6c3dc149 100644
--- a/invokeai/app/invocations/primitives.py
+++ b/invokeai/app/invocations/primitives.py
@@ -4,30 +4,36 @@
import torch
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import (
+ AnimaConditioningField,
+ BoundingBoxField,
+ CogView4ConditioningField,
ColorField,
ConditioningField,
DenoiseMaskField,
FieldDescriptions,
+ FluxConditioningField,
ImageField,
Input,
InputField,
LatentsField,
OutputField,
+ QwenImageConditioningField,
+ SD3ConditioningField,
TensorField,
UIComponent,
+ ZImageConditioningField,
)
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import (
- BaseInvocation,
- BaseInvocationOutput,
- invocation,
- invocation_output,
-)
-
"""
Primitives: Boolean, Integer, Float, String, Image, Latents, Conditioning, Color
- primitive nodes
@@ -263,13 +269,9 @@ class ImageInvocation(BaseInvocation):
image: ImageField = InputField(description="The image to load")
def invoke(self, context: InvocationContext) -> ImageOutput:
- image = context.images.get_pil(self.image.image_name)
+ image_dto = context.images.get_dto(self.image.image_name)
- return ImageOutput(
- image=ImageField(image_name=self.image.image_name),
- width=image.width,
- height=image.height,
- )
+ return ImageOutput.build(image_dto=image_dto)
@invocation(
@@ -414,11 +416,87 @@ def invoke(self, context: InvocationContext) -> ColorOutput:
class MaskOutput(BaseInvocationOutput):
"""A torch mask tensor."""
+ # shape: [1, H, W], dtype: bool
mask: TensorField = OutputField(description="The mask.")
width: int = OutputField(description="The width of the mask in pixels.")
height: int = OutputField(description="The height of the mask in pixels.")
+@invocation_output("flux_conditioning_output")
+class FluxConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a single conditioning tensor"""
+
+ conditioning: FluxConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "FluxConditioningOutput":
+ return cls(conditioning=FluxConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("flux_conditioning_collection_output")
+class FluxConditioningCollectionOutput(BaseInvocationOutput):
+ """Base class for nodes that output a collection of conditioning tensors"""
+
+ collection: list[FluxConditioningField] = OutputField(
+ description="The output conditioning tensors",
+ )
+
+
+@invocation_output("sd3_conditioning_output")
+class SD3ConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a single SD3 conditioning tensor"""
+
+ conditioning: SD3ConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "SD3ConditioningOutput":
+ return cls(conditioning=SD3ConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("cogview4_conditioning_output")
+class CogView4ConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a CogView text conditioning tensor."""
+
+ conditioning: CogView4ConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "CogView4ConditioningOutput":
+ return cls(conditioning=CogView4ConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("z_image_conditioning_output")
+class ZImageConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a Z-Image text conditioning tensor."""
+
+ conditioning: ZImageConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "ZImageConditioningOutput":
+ return cls(conditioning=ZImageConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("qwen_image_conditioning_output")
+class QwenImageConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output a Qwen Image Edit conditioning tensor."""
+
+ conditioning: QwenImageConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "QwenImageConditioningOutput":
+ return cls(conditioning=QwenImageConditioningField(conditioning_name=conditioning_name))
+
+
+@invocation_output("anima_conditioning_output")
+class AnimaConditioningOutput(BaseInvocationOutput):
+ """Base class for nodes that output an Anima text conditioning tensor."""
+
+ conditioning: AnimaConditioningField = OutputField(description=FieldDescriptions.cond)
+
+ @classmethod
+ def build(cls, conditioning_name: str) -> "AnimaConditioningOutput":
+ return cls(conditioning=AnimaConditioningField(conditioning_name=conditioning_name))
+
+
@invocation_output("conditioning_output")
class ConditioningOutput(BaseInvocationOutput):
"""Base class for nodes that output a single conditioning tensor"""
@@ -475,3 +553,42 @@ def invoke(self, context: InvocationContext) -> ConditioningCollectionOutput:
# endregion
+
+# region BoundingBox
+
+
+@invocation_output("bounding_box_output")
+class BoundingBoxOutput(BaseInvocationOutput):
+ """Base class for nodes that output a single bounding box"""
+
+ bounding_box: BoundingBoxField = OutputField(description="The output bounding box.")
+
+
+@invocation_output("bounding_box_collection_output")
+class BoundingBoxCollectionOutput(BaseInvocationOutput):
+ """Base class for nodes that output a collection of bounding boxes"""
+
+ collection: list[BoundingBoxField] = OutputField(description="The output bounding boxes.", title="Bounding Boxes")
+
+
+@invocation(
+ "bounding_box",
+ title="Bounding Box",
+ tags=["primitives", "segmentation", "collection", "bounding box"],
+ category="primitives",
+ version="1.0.0",
+)
+class BoundingBoxInvocation(BaseInvocation):
+ """Create a bounding box manually by supplying box coordinates"""
+
+ x_min: int = InputField(default=0, description="x-coordinate of the bounding box's top left vertex")
+ y_min: int = InputField(default=0, description="y-coordinate of the bounding box's top left vertex")
+ x_max: int = InputField(default=0, description="x-coordinate of the bounding box's bottom right vertex")
+ y_max: int = InputField(default=0, description="y-coordinate of the bounding box's bottom right vertex")
+
+ def invoke(self, context: InvocationContext) -> BoundingBoxOutput:
+ bounding_box = BoundingBoxField(x_min=self.x_min, y_min=self.y_min, x_max=self.x_max, y_max=self.y_max)
+ return BoundingBoxOutput(bounding_box=bounding_box)
+
+
+# endregion
diff --git a/invokeai/app/invocations/prompt.py b/invokeai/app/invocations/prompt.py
index 64a06d2f184..48eec0ac0ef 100644
--- a/invokeai/app/invocations/prompt.py
+++ b/invokeai/app/invocations/prompt.py
@@ -5,12 +5,11 @@
from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator
from pydantic import field_validator
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import InputField, UIComponent
from invokeai.app.invocations.primitives import StringCollectionOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField, UIComponent
-
@invocation(
"dynamic_prompt",
diff --git a/invokeai/app/invocations/prompt_template.py b/invokeai/app/invocations/prompt_template.py
new file mode 100644
index 00000000000..d2ac86358e5
--- /dev/null
+++ b/invokeai/app/invocations/prompt_template.py
@@ -0,0 +1,57 @@
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.fields import InputField, OutputField, StylePresetField, UIComponent
+from invokeai.app.services.shared.invocation_context import InvocationContext
+
+
+@invocation_output("prompt_template_output")
+class PromptTemplateOutput(BaseInvocationOutput):
+ """Output for the Prompt Template node"""
+
+ positive_prompt: str = OutputField(description="The positive prompt with the template applied")
+ negative_prompt: str = OutputField(description="The negative prompt with the template applied")
+
+
+@invocation(
+ "prompt_template",
+ title="Prompt Template",
+ tags=["prompt", "template", "style", "preset"],
+ category="prompt",
+ version="1.0.0",
+)
+class PromptTemplateInvocation(BaseInvocation):
+ """Applies a Style Preset template to positive and negative prompts.
+
+ Select a Style Preset and provide positive/negative prompts. The node replaces
+ {prompt} placeholders in the template with your input prompts.
+ """
+
+ style_preset: StylePresetField = InputField(
+ description="The Style Preset to use as a template",
+ )
+ positive_prompt: str = InputField(
+ default="",
+ description="The positive prompt to insert into the template's {prompt} placeholder",
+ ui_component=UIComponent.Textarea,
+ )
+ negative_prompt: str = InputField(
+ default="",
+ description="The negative prompt to insert into the template's {prompt} placeholder",
+ ui_component=UIComponent.Textarea,
+ )
+
+ def invoke(self, context: InvocationContext) -> PromptTemplateOutput:
+ # Fetch the style preset from the database
+ style_preset = context._services.style_preset_records.get(self.style_preset.style_preset_id)
+
+ # Get the template prompts
+ positive_template = style_preset.preset_data.positive_prompt
+ negative_template = style_preset.preset_data.negative_prompt
+
+ # Replace {prompt} placeholder with the input prompts
+ rendered_positive = positive_template.replace("{prompt}", self.positive_prompt)
+ rendered_negative = negative_template.replace("{prompt}", self.negative_prompt)
+
+ return PromptTemplateOutput(
+ positive_prompt=rendered_positive,
+ negative_prompt=rendered_negative,
+ )
diff --git a/invokeai/app/invocations/qwen_image_denoise.py b/invokeai/app/invocations/qwen_image_denoise.py
new file mode 100644
index 00000000000..2dabc929bb1
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_denoise.py
@@ -0,0 +1,559 @@
+import math
+from contextlib import ExitStack
+from typing import Callable, ClassVar, Iterator, Optional, Tuple
+
+import torch
+import torchvision.transforms as tv_transforms
+from diffusers.models.transformers.transformer_qwenimage import QwenImageTransformer2DModel
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ QwenImageConditioningField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import TransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.qwen_image_lora_constants import (
+ QWEN_IMAGE_EDIT_LORA_TRANSFORMER_PREFIX,
+)
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import QwenImageConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "qwen_image_denoise",
+ title="Denoise - Qwen Image",
+ tags=["image", "qwen_image"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class QwenImageDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Run the denoising process with a Qwen Image model."""
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.latents, input=Input.Connection
+ )
+ # Reference image latents (encoded through VAE) to concatenate with noisy latents.
+ reference_latents: Optional[LatentsField] = InputField(
+ default=None,
+ description="Reference image latents to guide generation. Encoded through the VAE.",
+ input=Input.Connection,
+ )
+ # denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
+ )
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.qwen_image_model, input=Input.Connection, title="Transformer"
+ )
+ positive_conditioning: QwenImageConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: Optional[QwenImageConditioningField] = InputField(
+ default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ cfg_scale: float | list[float] = InputField(default=4.0, description=FieldDescriptions.cfg_scale, title="CFG Scale")
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ steps: int = InputField(default=40, gt=0, description=FieldDescriptions.steps)
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+ shift: Optional[float] = InputField(
+ default=None,
+ description="Override the sigma schedule shift. "
+ "When set, uses a fixed shift (e.g. 3.0 for Lightning LoRAs) instead of the default dynamic shifting. "
+ "Leave unset for the base model's default schedule.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask
+
+ def _load_text_conditioning(
+ self,
+ context: InvocationContext,
+ conditioning_name: str,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ cond_data = context.conditioning.load(conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ conditioning = cond_data.conditionings[0]
+ assert isinstance(conditioning, QwenImageConditioningInfo)
+ conditioning = conditioning.to(dtype=dtype, device=device)
+ return conditioning.prompt_embeds, conditioning.prompt_embeds_mask
+
+ def _get_noise(
+ self,
+ batch_size: int,
+ num_channels_latents: int,
+ height: int,
+ width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ seed: int,
+ ) -> torch.Tensor:
+ rand_device = "cpu"
+ rand_dtype = torch.float32
+
+ return torch.randn(
+ batch_size,
+ num_channels_latents,
+ int(height) // LATENT_SCALE_FACTOR,
+ int(width) // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+ def _prepare_cfg_scale(self, num_timesteps: int) -> list[float]:
+ if isinstance(self.cfg_scale, float):
+ cfg_scale = [self.cfg_scale] * num_timesteps
+ elif isinstance(self.cfg_scale, list):
+ assert len(self.cfg_scale) == num_timesteps
+ cfg_scale = self.cfg_scale
+ else:
+ raise ValueError(f"Invalid CFG scale type: {type(self.cfg_scale)}")
+ return cfg_scale
+
+ @staticmethod
+ def _pack_latents(
+ latents: torch.Tensor, batch_size: int, num_channels: int, height: int, width: int
+ ) -> torch.Tensor:
+ """Pack 4D latents (B, C, H, W) into 2x2-patched 3D (B, H/2*W/2, C*4)."""
+ latents = latents.view(batch_size, num_channels, height // 2, 2, width // 2, 2)
+ latents = latents.permute(0, 2, 4, 1, 3, 5)
+ latents = latents.reshape(batch_size, (height // 2) * (width // 2), num_channels * 4)
+ return latents
+
+ @staticmethod
+ def _unpack_latents(latents: torch.Tensor, height: int, width: int) -> torch.Tensor:
+ """Unpack 3D patched latents (B, seq, C*4) back to 4D (B, C, H, W)."""
+ batch_size, _num_patches, channels = latents.shape
+ # height/width are in latent space; they must be divisible by 2 for packing
+ h = 2 * (height // 2)
+ w = 2 * (width // 2)
+ latents = latents.view(batch_size, h // 2, w // 2, channels // 4, 2, 2)
+ latents = latents.permute(0, 3, 1, 4, 2, 5)
+ latents = latents.reshape(batch_size, channels // 4, h, w)
+ return latents
+
+ @staticmethod
+ def _align_ref_latent_dims(rh: int, rw: int) -> tuple[int, int]:
+ """Trim reference latent spatial dims to even values for 2x2 packing.
+
+ Raises ValueError if the aligned dims would be < 2 (i.e., the reference
+ latent is too small to produce any valid tokens).
+ """
+ rh_aligned = rh - (rh % 2)
+ rw_aligned = rw - (rw % 2)
+ if rh_aligned < 2 or rw_aligned < 2:
+ raise ValueError(
+ f"Reference latent spatial dims must be >= 2 after even alignment; "
+ f"got ({rh_aligned}, {rw_aligned}) from input shape ({rh}, {rw}). "
+ "Ensure the reference image is at least 16 pixels in each dimension."
+ )
+ return rh_aligned, rw_aligned
+
+ @staticmethod
+ def _build_img_shapes(
+ latent_height: int,
+ latent_width: int,
+ ref_latent_height: int | None = None,
+ ref_latent_width: int | None = None,
+ ) -> list[list[tuple[int, int, int]]]:
+ """Build the img_shapes argument for the transformer.
+
+ The reference segment (if present) must use its own dims so QwenEmbedRope's
+ spatial frequencies position ref tokens distinctly from noisy tokens —
+ otherwise reference content bleeds into the generation as a ghost.
+ """
+ shapes: list[tuple[int, int, int]] = [(1, latent_height // 2, latent_width // 2)]
+ if ref_latent_height is not None and ref_latent_width is not None:
+ shapes.append((1, ref_latent_height // 2, ref_latent_width // 2))
+ return [shapes]
+
+ # diffusers' QwenImageEdit(Plus)Pipeline VAE_IMAGE_SIZE = 1024 * 1024 pixels;
+ # ref images are resized to this area (preserving aspect, snapped to multiples
+ # of 32) before VAE encoding. We mirror this clamp in latent space so direct
+ # backend callers — whose i2l may not pass explicit width/height — don't feed
+ # the transformer an out-of-distribution reference sequence length (which
+ # also causes a VRAM spike for large inputs).
+ _REF_TARGET_PIXEL_AREA: ClassVar[int] = 1024 * 1024
+ _VAE_SCALE_FACTOR: ClassVar[int] = 8
+
+ @classmethod
+ def _maybe_clamp_ref_latent_size(cls, ref_latents: torch.Tensor) -> torch.Tensor:
+ """Bilinear-downscale the reference latent if it exceeds diffusers'
+ VAE_IMAGE_SIZE budget.
+
+ Returns the latent unchanged if it's already within budget.
+ """
+ _, _, rh, rw = ref_latents.shape
+ target_cells = cls._REF_TARGET_PIXEL_AREA // (cls._VAE_SCALE_FACTOR**2)
+ if rh * rw <= target_cells:
+ return ref_latents
+ aspect = rw / rh
+ target_w_px = math.sqrt(cls._REF_TARGET_PIXEL_AREA * aspect)
+ target_h_px = target_w_px / aspect
+ target_w_px = max(32, round(target_w_px / 32) * 32)
+ target_h_px = max(32, round(target_h_px / 32) * 32)
+ target_rh = target_h_px // cls._VAE_SCALE_FACTOR
+ target_rw = target_w_px // cls._VAE_SCALE_FACTOR
+ return torch.nn.functional.interpolate(
+ ref_latents, size=(target_rh, target_rw), mode="bilinear", antialias=False
+ )
+
+ def _run_diffusion(self, context: InvocationContext):
+ inference_dtype = torch.bfloat16
+ device = TorchDevice.choose_torch_device()
+
+ transformer_info = context.models.load(self.transformer.transformer)
+ assert isinstance(transformer_info.model, QwenImageTransformer2DModel)
+
+ # Load conditioning
+ pos_prompt_embeds, pos_prompt_mask = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.positive_conditioning.conditioning_name,
+ dtype=inference_dtype,
+ device=device,
+ )
+
+ neg_prompt_embeds = None
+ neg_prompt_mask = None
+ # Match the diffusers pipeline: only enable CFG when cfg_scale > 1 AND negative conditioning is provided.
+ # With cfg_scale <= 1, the negative prediction is unused, so skip it entirely.
+ # For per-step arrays, enable CFG if any step has scale > 1.
+ if isinstance(self.cfg_scale, list):
+ any_cfg_above_one = any(v > 1.0 for v in self.cfg_scale)
+ else:
+ any_cfg_above_one = self.cfg_scale > 1.0
+ do_classifier_free_guidance = self.negative_conditioning is not None and any_cfg_above_one
+ if do_classifier_free_guidance:
+ neg_prompt_embeds, neg_prompt_mask = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.negative_conditioning.conditioning_name,
+ dtype=inference_dtype,
+ device=device,
+ )
+
+ # Prepare the timestep / sigma schedule
+ patch_size = transformer_info.model.config.patch_size
+ assert isinstance(patch_size, int)
+ # Output channels is 16 (the actual latent channels)
+ out_channels = transformer_info.model.config.out_channels
+ assert isinstance(out_channels, int)
+
+ latent_height = self.height // LATENT_SCALE_FACTOR
+ latent_width = self.width // LATENT_SCALE_FACTOR
+ image_seq_len = (latent_height * latent_width) // (patch_size**2)
+
+ # Use the actual FlowMatchEulerDiscreteScheduler to compute sigmas/timesteps,
+ # exactly matching the diffusers pipeline.
+ import math
+
+ import numpy as np
+ from diffusers.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
+
+ # Try to load the scheduler config from the model's directory (Diffusers models
+ # have a scheduler/ subdir). For GGUF models this path doesn't exist, so fall
+ # back to instantiating the scheduler with the known Qwen Image defaults.
+ model_path = context.models.get_absolute_path(context.models.get_config(self.transformer.transformer))
+ scheduler_path = model_path / "scheduler"
+ if scheduler_path.is_dir() and (scheduler_path / "scheduler_config.json").exists():
+ scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(str(scheduler_path), local_files_only=True)
+ else:
+ scheduler = FlowMatchEulerDiscreteScheduler(
+ use_dynamic_shifting=True,
+ base_shift=0.5,
+ max_shift=0.9,
+ base_image_seq_len=256,
+ max_image_seq_len=8192,
+ shift_terminal=0.02,
+ num_train_timesteps=1000,
+ time_shift_type="exponential",
+ )
+
+ if self.shift is not None:
+ # Lightning LoRA: fixed shift
+ mu = math.log(self.shift)
+ else:
+ # Default dynamic shifting
+ # Linear interpolation matching diffusers' calculate_shift
+ base_shift = scheduler.config.get("base_shift", 0.5)
+ max_shift = scheduler.config.get("max_shift", 0.9)
+ base_seq = scheduler.config.get("base_image_seq_len", 256)
+ max_seq = scheduler.config.get("max_image_seq_len", 4096)
+ m = (max_shift - base_shift) / (max_seq - base_seq)
+ b = base_shift - m * base_seq
+ mu = image_seq_len * m + b
+
+ init_sigmas = np.linspace(1.0, 1.0 / self.steps, self.steps).tolist()
+ scheduler.set_timesteps(sigmas=init_sigmas, mu=mu, device=device)
+
+ # Clip the schedule based on denoising_start/denoising_end to support img2img strength.
+ # The scheduler's sigmas go from high (noisy) to 0 (clean). We clip to the fractional range.
+ sigmas_sched = scheduler.sigmas # (N+1,) including terminal 0
+ if self.denoising_start > 0 or self.denoising_end < 1:
+ total_sigmas = len(sigmas_sched) - 1 # exclude terminal
+ start_idx = int(round(self.denoising_start * total_sigmas))
+ end_idx = int(round(self.denoising_end * total_sigmas))
+ sigmas_sched = sigmas_sched[start_idx : end_idx + 1] # +1 to include the next sigma for dt
+ # Rebuild timesteps from clipped sigmas (exclude terminal 0)
+ timesteps_sched = sigmas_sched[:-1] * scheduler.config.num_train_timesteps
+ else:
+ timesteps_sched = scheduler.timesteps
+
+ total_steps = len(timesteps_sched)
+
+ cfg_scale = self._prepare_cfg_scale(total_steps)
+
+ # Load initial latents if provided (for img2img)
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+ if init_latents.dim() == 5:
+ init_latents = init_latents.squeeze(2)
+
+ # Load reference image latents if provided
+ ref_latents = None
+ if self.reference_latents is not None:
+ ref_latents = context.tensors.load(self.reference_latents.latents_name)
+ ref_latents = ref_latents.to(device=device, dtype=inference_dtype)
+ # The VAE encoder produces 5D latents (B, C, 1, H, W); squeeze the frame dim
+ # so we have 4D (B, C, H, W) for packing.
+ if ref_latents.dim() == 5:
+ ref_latents = ref_latents.squeeze(2)
+
+ # Generate noise (16 channels - the output latent channels)
+ noise = self._get_noise(
+ batch_size=1,
+ num_channels_latents=out_channels,
+ height=self.height,
+ width=self.width,
+ dtype=inference_dtype,
+ device=device,
+ seed=self.seed,
+ )
+
+ # Prepare input latent image
+ if init_latents is not None:
+ s_0 = sigmas_sched[0].item()
+ latents = s_0 * noise + (1.0 - s_0) * init_latents
+ else:
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ latents = noise
+
+ if total_steps <= 0:
+ return latents
+
+ # Pack latents into 2x2 patches: (B, C, H, W) -> (B, H/2*W/2, C*4)
+ latents = self._pack_latents(latents, 1, out_channels, latent_height, latent_width)
+
+ # Determine whether the model uses reference latent conditioning (zero_cond_t).
+ # Edit models (zero_cond_t=True) expect [noisy_patches ; ref_patches] in the sequence.
+ # Txt2img models (zero_cond_t=False) only take noisy patches.
+ has_zero_cond_t = getattr(transformer_info.model, "zero_cond_t", False) or getattr(
+ transformer_info.model.config, "zero_cond_t", False
+ )
+ use_ref_latents = has_zero_cond_t
+
+ ref_latents_packed = None
+ ref_latent_height = latent_height
+ ref_latent_width = latent_width
+ if use_ref_latents:
+ if ref_latents is not None:
+ # Defense-in-depth: backend callers (direct API, older graph JSON)
+ # may wire qwen_image_i2l without explicit width/height, producing
+ # a native-resolution reference latent. Clamp here so the
+ # transformer always sees an in-distribution sequence length.
+ ref_latents = self._maybe_clamp_ref_latent_size(ref_latents)
+ _, _, rh, rw = ref_latents.shape
+ ref_latent_height, ref_latent_width = self._align_ref_latent_dims(rh, rw)
+ if ref_latent_height != rh or ref_latent_width != rw:
+ ref_latents = ref_latents[..., :ref_latent_height, :ref_latent_width]
+ else:
+ # No reference image provided — use zeros so the model still gets the
+ # expected sequence layout.
+ ref_latents = torch.zeros(
+ 1, out_channels, latent_height, latent_width, device=device, dtype=inference_dtype
+ )
+ ref_latents_packed = self._pack_latents(ref_latents, 1, out_channels, ref_latent_height, ref_latent_width)
+
+ # img_shapes tells the transformer the spatial layout of patches. The reference
+ # segment must use the reference latent's own dimensions so RoPE positions it
+ # distinctly from the noisy latent — otherwise the two segments share spatial
+ # positional encoding and the model can't disentangle them, producing a
+ # ghost/doubling artifact across the whole frame. Matches diffusers'
+ # QwenImageEditPipeline / QwenImageEditPlusPipeline.
+ if use_ref_latents:
+ img_shapes = self._build_img_shapes(latent_height, latent_width, ref_latent_height, ref_latent_width)
+ else:
+ img_shapes = self._build_img_shapes(latent_height, latent_width)
+
+ # Prepare inpaint extension (operates in 4D space, so unpack/repack around it)
+ inpaint_mask = self._prep_inpaint_mask(context, noise) # noise has the right 4D shape
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ assert init_latents is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents,
+ inpaint_mask=inpaint_mask,
+ noise=noise,
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=0,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(timesteps_sched[0].item()) if len(timesteps_sched) > 0 else 0,
+ latents=self._unpack_latents(latents, latent_height, latent_width),
+ ),
+ )
+
+ noisy_seq_len = latents.shape[1]
+
+ # Determine if the model is quantized — GGUF models need sidecar patching for LoRAs
+ transformer_config = context.models.get_config(self.transformer.transformer)
+ model_is_quantized = transformer_config.format in (ModelFormat.GGUFQuantized,)
+
+ with ExitStack() as exit_stack:
+ (cached_weights, transformer) = exit_stack.enter_context(transformer_info.model_on_device())
+ assert isinstance(transformer, QwenImageTransformer2DModel)
+
+ # Apply LoRA patches to the transformer
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=transformer,
+ patches=self._lora_iterator(context),
+ prefix=QWEN_IMAGE_EDIT_LORA_TRANSFORMER_PREFIX,
+ dtype=inference_dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=model_is_quantized,
+ )
+ )
+
+ for step_idx, t in enumerate(tqdm(timesteps_sched)):
+ # The pipeline passes timestep / 1000 to the transformer
+ timestep = t.expand(latents.shape[0]).to(inference_dtype)
+
+ # For edit models: concatenate noisy and reference patches along the sequence dim
+ # For txt2img models: just use noisy patches
+ if ref_latents_packed is not None:
+ model_input = torch.cat([latents, ref_latents_packed], dim=1)
+ else:
+ model_input = latents
+
+ noise_pred_cond = transformer(
+ hidden_states=model_input,
+ encoder_hidden_states=pos_prompt_embeds,
+ encoder_hidden_states_mask=pos_prompt_mask,
+ timestep=timestep / 1000,
+ img_shapes=img_shapes,
+ return_dict=False,
+ )[0]
+ # Only keep the noisy-latent portion of the output
+ noise_pred_cond = noise_pred_cond[:, :noisy_seq_len]
+
+ if do_classifier_free_guidance and neg_prompt_embeds is not None:
+ noise_pred_uncond = transformer(
+ hidden_states=model_input,
+ encoder_hidden_states=neg_prompt_embeds,
+ encoder_hidden_states_mask=neg_prompt_mask,
+ timestep=timestep / 1000,
+ img_shapes=img_shapes,
+ return_dict=False,
+ )[0]
+ noise_pred_uncond = noise_pred_uncond[:, :noisy_seq_len]
+
+ noise_pred = noise_pred_uncond + cfg_scale[step_idx] * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ # Euler step using the (possibly clipped) sigma schedule
+ sigma_curr = sigmas_sched[step_idx]
+ sigma_next = sigmas_sched[step_idx + 1]
+ dt = sigma_next - sigma_curr
+ latents = latents.to(torch.float32) + dt * noise_pred.to(torch.float32)
+ latents = latents.to(inference_dtype)
+
+ if inpaint_extension is not None:
+ sigma_next = sigmas_sched[step_idx + 1].item()
+ latents_4d = self._unpack_latents(latents, latent_height, latent_width)
+ latents_4d = inpaint_extension.merge_intermediate_latents_with_init_latents(latents_4d, sigma_next)
+ latents = self._pack_latents(latents_4d, 1, out_channels, latent_height, latent_width)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(t.item()),
+ latents=self._unpack_latents(latents, latent_height, latent_width),
+ ),
+ )
+
+ # Unpack back to 4D then add frame dim for the video-style VAE: (B, C, 1, H, W)
+ latents = self._unpack_latents(latents, latent_height, latent_width)
+ latents = latents.unsqueeze(2)
+ return latents
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.QwenImage)
+
+ return step_callback
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the transformer."""
+ for lora in self.transformer.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/qwen_image_image_to_latents.py b/invokeai/app/invocations/qwen_image_image_to_latents.py
new file mode 100644
index 00000000000..cac536f00c9
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_image_to_latents.py
@@ -0,0 +1,102 @@
+import einops
+import torch
+from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
+from PIL import Image as PILImage
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_qwen_image
+
+
+@invocation(
+ "qwen_image_i2l",
+ title="Image to Latents - Qwen Image",
+ tags=["image", "latents", "vae", "i2l", "qwen_image"],
+ category="image",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class QwenImageImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates latents from an image using the Qwen Image VAE."""
+
+ image: ImageField = InputField(description="The image to encode.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+ width: int | None = InputField(
+ default=None,
+ description="Resize the image to this width before encoding. If not set, encodes at the image's original size.",
+ )
+ height: int | None = InputField(
+ default=None,
+ description="Resize the image to this height before encoding. If not set, encodes at the image's original size.",
+ )
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ # Reserve working memory for the encode so the cache offloads any large resident model first;
+ # otherwise the encode's activations OOM (the VAE weights themselves are tiny).
+ estimated_working_memory = estimate_vae_working_memory_qwen_image("encode", image_tensor, vae_info.model)
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoencoderKLQwenImage)
+
+ vae.disable_tiling()
+
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae.dtype)
+ with torch.inference_mode():
+ # The Qwen Image VAE expects 5D input: (B, C, num_frames, H, W)
+ if image_tensor.dim() == 4:
+ image_tensor = image_tensor.unsqueeze(2)
+
+ posterior = vae.encode(image_tensor).latent_dist
+ # Use mode (argmax) for deterministic encoding, matching diffusers
+ latents: torch.Tensor = posterior.mode().to(dtype=vae.dtype)
+
+ # Normalize with per-channel latents_mean / latents_std
+ latents_mean = (
+ torch.tensor(vae.config.latents_mean)
+ .view(1, vae.config.z_dim, 1, 1, 1)
+ .to(latents.device, latents.dtype)
+ )
+ latents_std = (
+ torch.tensor(vae.config.latents_std)
+ .view(1, vae.config.z_dim, 1, 1, 1)
+ .to(latents.device, latents.dtype)
+ )
+ latents = (latents - latents_mean) / latents_std
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ # If target dimensions are specified, resize the image BEFORE encoding
+ # (matching the diffusers pipeline which resizes in pixel space, not latent space).
+ if self.width is not None and self.height is not None:
+ image = image.convert("RGB").resize((self.width, self.height), resample=PILImage.LANCZOS)
+
+ # multiple_of=16 ensures the post-VAE latents (vae_scale_factor=8) have even
+ # spatial dims, which the transformer's 2x2 patch packing requires.
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"), multiple_of=16)
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/qwen_image_latents_to_image.py b/invokeai/app/invocations/qwen_image_latents_to_image.py
new file mode 100644
index 00000000000..c418fe43cbe
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_latents_to_image.py
@@ -0,0 +1,97 @@
+from contextlib import nullcontext
+
+import torch
+from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_qwen_image
+
+
+@invocation(
+ "qwen_image_l2i",
+ title="Latents to Image - Qwen Image",
+ tags=["latents", "image", "vae", "l2i", "qwen_image"],
+ category="latents",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class QwenImageLatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents using the Qwen Image VAE."""
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, AutoencoderKLQwenImage)
+ # Reserve working memory for the decode so the cache offloads any large resident model (e.g.
+ # the transformer) first; otherwise the decode's activations OOM. See estimator for details.
+ estimated_working_memory = estimate_vae_working_memory_qwen_image("decode", latents, vae_info.model)
+ with (
+ SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes),
+ vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae),
+ ):
+ context.util.signal_progress("Running VAE")
+ assert isinstance(vae, AutoencoderKLQwenImage)
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae.dtype)
+
+ # Honor the global force_tiled_decode setting, like the SD/SDXL l2i node. Tiling bounds the
+ # VAE's per-tile memory, which is the scalable way to decode very large outputs that would
+ # exceed VRAM even after offloading the transformer/text encoder. For normal sizes, leave
+ # it off (faster, no tile blending) — the reserved working memory offloads other models so
+ # the full-frame decode fits.
+ if context.config.get().force_tiled_decode:
+ vae.enable_tiling()
+ else:
+ vae.disable_tiling()
+
+ tiling_context = nullcontext()
+
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode(), tiling_context:
+ # The Qwen Image VAE uses per-channel latents_mean / latents_std
+ # instead of a single scaling_factor.
+ # Latents are 5D: (B, C, num_frames, H, W) — the unpack from the
+ # denoise step already produces this shape.
+ latents_mean = (
+ torch.tensor(vae.config.latents_mean)
+ .view(1, vae.config.z_dim, 1, 1, 1)
+ .to(latents.device, latents.dtype)
+ )
+ latents_std = 1.0 / torch.tensor(vae.config.latents_std).view(1, vae.config.z_dim, 1, 1, 1).to(
+ latents.device, latents.dtype
+ )
+ latents = latents / latents_std + latents_mean
+
+ img = vae.decode(latents, return_dict=False)[0]
+ # Drop the temporal frame dimension: (B, C, 1, H, W) -> (B, C, H, W)
+ img = img[:, :, 0]
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c")
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/qwen_image_lora_loader.py b/invokeai/app/invocations/qwen_image_lora_loader.py
new file mode 100644
index 00000000000..f670b2d8954
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_lora_loader.py
@@ -0,0 +1,115 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, TransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("qwen_image_lora_loader_output")
+class QwenImageLoRALoaderOutput(BaseInvocationOutput):
+ """Qwen Image LoRA Loader Output"""
+
+ transformer: Optional[TransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="Transformer"
+ )
+
+
+@invocation(
+ "qwen_image_lora_loader",
+ title="Apply LoRA - Qwen Image",
+ tags=["lora", "model", "qwen_image"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class QwenImageLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA model to a Qwen Image transformer."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.QwenImage,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=1.0, description=FieldDescriptions.lora_weight)
+ transformer: TransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+
+ def invoke(self, context: InvocationContext) -> QwenImageLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ if self.transformer and any(lora.lora.key == lora_key for lora in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to transformer.')
+
+ output = QwenImageLoRALoaderOutput()
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ output.transformer.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "qwen_image_lora_collection_loader",
+ title="Apply LoRA Collection - Qwen Image",
+ tags=["lora", "model", "qwen_image"],
+ category="model",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class QwenImageLoRACollectionLoader(BaseInvocation):
+ """Applies a collection of LoRAs to a Qwen Image transformer."""
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+ transformer: Optional[TransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+
+ def invoke(self, context: InvocationContext) -> QwenImageLoRALoaderOutput:
+ output = QwenImageLoRALoaderOutput()
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added_loras: list[str] = []
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ for lora in loras:
+ if lora is None:
+ continue
+ if lora.lora.key in added_loras:
+ continue
+ if not context.models.exists(lora.lora.key):
+ raise Exception(f"Unknown lora: {lora.lora.key}!")
+
+ added_loras.append(lora.lora.key)
+
+ if self.transformer is not None and output.transformer is not None:
+ output.transformer.loras.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/qwen_image_model_loader.py b/invokeai/app/invocations/qwen_image_model_loader.py
new file mode 100644
index 00000000000..b3e86d1bf4a
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_model_loader.py
@@ -0,0 +1,147 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ ModelIdentifierField,
+ QwenVLEncoderField,
+ TransformerField,
+ VAEField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType
+
+
+@invocation_output("qwen_image_model_loader_output")
+class QwenImageModelLoaderOutput(BaseInvocationOutput):
+ """Qwen Image model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ qwen_vl_encoder: QwenVLEncoderField = OutputField(
+ description=FieldDescriptions.qwen_vl_encoder, title="Qwen VL Encoder"
+ )
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "qwen_image_model_loader",
+ title="Main Model - Qwen Image",
+ tags=["model", "qwen_image"],
+ category="model",
+ version="1.2.0",
+ classification=Classification.Prototype,
+)
+class QwenImageModelLoaderInvocation(BaseInvocation):
+ """Loads a Qwen Image model, outputting its submodels.
+
+ The transformer is always loaded from the main model (Diffusers or GGUF).
+
+ Components can be mixed and matched:
+ - VAE: standalone Qwen Image VAE checkpoint, the Component Source (Diffusers),
+ or the main model if it's Diffusers.
+ - Qwen VL Encoder: standalone Qwen2.5-VL encoder, the Component Source
+ (Diffusers), or the main model if it's Diffusers.
+
+ Together, the standalone VAE and standalone encoder allow running a GGUF
+ transformer without ever downloading the full ~40 GB Diffusers pipeline.
+ """
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.qwen_image_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.QwenImage,
+ ui_model_type=ModelType.Main,
+ title="Transformer",
+ )
+
+ vae_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Qwen Image VAE model. "
+ "If not provided, VAE will be loaded from the Component Source (or from the main model if it is Diffusers).",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.QwenImage,
+ ui_model_type=ModelType.VAE,
+ title="VAE",
+ )
+
+ qwen_vl_encoder_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Qwen2.5-VL encoder model. "
+ "If not provided, the encoder will be loaded from the Component Source "
+ "(or from the main model if it is Diffusers).",
+ input=Input.Direct,
+ ui_model_type=ModelType.QwenVLEncoder,
+ title="Qwen VL Encoder",
+ )
+
+ component_source: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Diffusers Qwen Image model to extract VAE and/or Qwen VL encoder from. "
+ "Use this if you don't have separate VAE/encoder models. "
+ "Ignored for any submodel that is provided separately.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.QwenImage,
+ ui_model_type=ModelType.Main,
+ ui_model_format=ModelFormat.Diffusers,
+ title="Component Source (Diffusers)",
+ )
+
+ def invoke(self, context: InvocationContext) -> QwenImageModelLoaderOutput:
+ main_config = context.models.get_config(self.model)
+ main_is_diffusers = main_config.format == ModelFormat.Diffusers
+
+ # Transformer always comes from the main model
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+
+ # Resolve VAE: standalone override > main (if Diffusers) > component source
+ if self.vae_model is not None:
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif main_is_diffusers:
+ vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif self.component_source is not None:
+ self._validate_component_source_format(context, self.component_source)
+ vae = self.component_source.model_copy(update={"submodel_type": SubModelType.VAE})
+ else:
+ raise ValueError(
+ "No source for VAE. Either set 'VAE' to a standalone Qwen Image VAE, "
+ "or set 'Component Source' to a Diffusers Qwen Image model."
+ )
+
+ # Resolve Qwen VL encoder: standalone override > main (if Diffusers) > component source
+ if self.qwen_vl_encoder_model is not None:
+ tokenizer = self.qwen_vl_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.qwen_vl_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif main_is_diffusers:
+ tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif self.component_source is not None:
+ self._validate_component_source_format(context, self.component_source)
+ tokenizer = self.component_source.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ text_encoder = self.component_source.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ else:
+ raise ValueError(
+ "No source for Qwen VL encoder. "
+ "Either set 'Qwen VL Encoder' to a standalone Qwen2.5-VL encoder, "
+ "or set 'Component Source' to a Diffusers Qwen Image model."
+ )
+
+ return QwenImageModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ qwen_vl_encoder=QwenVLEncoderField(tokenizer=tokenizer, text_encoder=text_encoder),
+ vae=VAEField(vae=vae),
+ )
+
+ @staticmethod
+ def _validate_component_source_format(context: InvocationContext, model: ModelIdentifierField) -> None:
+ source_config = context.models.get_config(model)
+ if source_config.format != ModelFormat.Diffusers:
+ raise ValueError(
+ f"The Component Source model must be in Diffusers format. "
+ f"The selected model '{source_config.name}' is in {source_config.format.value} format."
+ )
diff --git a/invokeai/app/invocations/qwen_image_text_encoder.py b/invokeai/app/invocations/qwen_image_text_encoder.py
new file mode 100644
index 00000000000..9d9347a8cf9
--- /dev/null
+++ b/invokeai/app/invocations/qwen_image_text_encoder.py
@@ -0,0 +1,323 @@
+from typing import Literal
+
+import torch
+from PIL import Image as PILImage
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ UIComponent,
+)
+from invokeai.app.invocations.model import QwenVLEncoderField
+from invokeai.app.invocations.primitives import QwenImageConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ ConditioningFieldData,
+ QwenImageConditioningInfo,
+)
+
+# Prompt templates and drop indices for the two Qwen Image model modes.
+# These are taken directly from the diffusers pipelines.
+
+# Image editing mode (QwenImagePipeline)
+_EDIT_SYSTEM_PROMPT = (
+ "Describe the key features of the input image (color, shape, size, texture, objects, background), "
+ "then explain how the user's text instruction should alter or modify the image. "
+ "Generate a new image that meets the user's requirements while maintaining consistency "
+ "with the original input where appropriate."
+)
+_EDIT_DROP_IDX = 64
+
+# Text-to-image mode (QwenImagePipeline)
+_GENERATE_SYSTEM_PROMPT = (
+ "Describe the image by detailing the color, shape, size, texture, quantity, "
+ "text, spatial relationships of the objects and background:"
+)
+_GENERATE_DROP_IDX = 34
+
+_IMAGE_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>"
+
+
+def _build_prompt(user_prompt: str, num_images: int) -> str:
+ """Build the full prompt with the appropriate template based on whether reference images are provided."""
+ if num_images > 0:
+ # Edit mode: include vision placeholders for reference images
+ image_tokens = _IMAGE_PLACEHOLDER * num_images
+ return (
+ f"<|im_start|>system\n{_EDIT_SYSTEM_PROMPT}<|im_end|>\n"
+ f"<|im_start|>user\n{image_tokens}{user_prompt}<|im_end|>\n"
+ "<|im_start|>assistant\n"
+ )
+ else:
+ # Generate mode: text-only prompt
+ return (
+ f"<|im_start|>system\n{_GENERATE_SYSTEM_PROMPT}<|im_end|>\n"
+ f"<|im_start|>user\n{user_prompt}<|im_end|>\n"
+ "<|im_start|>assistant\n"
+ )
+
+
+@invocation(
+ "qwen_image_text_encoder",
+ title="Prompt - Qwen Image",
+ tags=["prompt", "conditioning", "qwen_image"],
+ category="conditioning",
+ version="1.2.0",
+ classification=Classification.Prototype,
+ idle_gpu_offloadable=True,
+)
+class QwenImageTextEncoderInvocation(BaseInvocation):
+ """Encodes text and reference images for Qwen Image using Qwen2.5-VL."""
+
+ prompt: str = InputField(description="Text prompt describing the desired edit.", ui_component=UIComponent.Textarea)
+ reference_images: list[ImageField] = InputField(
+ default=[],
+ description="Reference images to guide the edit. The model can use multiple reference images.",
+ )
+ qwen_vl_encoder: QwenVLEncoderField = InputField(
+ title="Qwen VL Encoder",
+ description=FieldDescriptions.qwen_vl_encoder,
+ input=Input.Connection,
+ )
+ quantization: Literal["none", "int8", "nf4"] = InputField(
+ default="none",
+ description="Quantize the Qwen VL encoder to reduce VRAM usage. "
+ "'nf4' (4-bit) saves the most memory, 'int8' (8-bit) is a middle ground.",
+ )
+
+ @staticmethod
+ def _resize_for_vl_encoder(image: PILImage.Image, target_pixels: int = 512 * 512) -> PILImage.Image:
+ """Resize image to fit within target_pixels while preserving aspect ratio.
+
+ Matches the diffusers pipeline's calculate_dimensions logic: the image is resized
+ so its total pixel count is approximately target_pixels, with dimensions rounded to
+ multiples of 32. This prevents large images from producing too many vision tokens
+ which can overwhelm the text prompt.
+ """
+ w, h = image.size
+ aspect = w / h
+ # Compute dimensions that preserve aspect ratio at ~target_pixels total
+ new_w = int((target_pixels * aspect) ** 0.5)
+ new_h = int(target_pixels / new_w)
+ # Round to multiples of 32
+ new_w = max(32, (new_w // 32) * 32)
+ new_h = max(32, (new_h // 32) * 32)
+ if new_w != w or new_h != h:
+ image = image.resize((new_w, new_h), resample=PILImage.LANCZOS)
+ return image
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> QwenImageConditioningOutput:
+ # Load and resize reference images to ~1M pixels (matching diffusers pipeline)
+ pil_images: list[PILImage.Image] = []
+ for img_field in self.reference_images:
+ pil_img = context.images.get_pil(img_field.image_name)
+ pil_img = self._resize_for_vl_encoder(pil_img.convert("RGB"))
+ pil_images.append(pil_img)
+
+ prompt_embeds, prompt_mask = self._encode(context, pil_images)
+ prompt_embeds = prompt_embeds.detach().to("cpu")
+ prompt_mask = prompt_mask.detach().to("cpu") if prompt_mask is not None else None
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[QwenImageConditioningInfo(prompt_embeds=prompt_embeds, prompt_embeds_mask=prompt_mask)]
+ )
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return QwenImageConditioningOutput.build(conditioning_name)
+
+ def _encode(
+ self, context: InvocationContext, images: list[PILImage.Image]
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
+ """Encode text prompt and reference images using Qwen2.5-VL.
+
+ Matches the diffusers QwenImagePipeline._get_qwen_prompt_embeds logic:
+ 1. Format prompt with the edit-specific system template
+ 2. Run through Qwen2.5-VL to get hidden states
+ 3. Extract valid (non-padding) tokens and drop the system prefix
+ 4. Return padded embeddings + attention mask
+ """
+ from transformers import AutoTokenizer, Qwen2_5_VLProcessor
+
+ try:
+ from transformers import Qwen2_5_VLImageProcessor as _ImageProcessorCls
+ except ImportError:
+ from transformers.models.qwen2_vl.image_processing_qwen2_vl import ( # type: ignore[no-redef]
+ Qwen2VLImageProcessor as _ImageProcessorCls,
+ )
+
+ try:
+ from transformers import Qwen2_5_VLVideoProcessor as _VideoProcessorCls
+ except ImportError:
+ from transformers.models.qwen2_vl.video_processing_qwen2_vl import ( # type: ignore[no-redef]
+ Qwen2VLVideoProcessor as _VideoProcessorCls,
+ )
+
+ # Format the prompt with one vision placeholder per reference image
+ text = _build_prompt(self.prompt, len(images))
+
+ # Build the processor
+ tokenizer_config = context.models.get_config(self.qwen_vl_encoder.tokenizer)
+ model_root = context.models.get_absolute_path(tokenizer_config)
+
+ # Single-file checkpoints (e.g. ComfyUI fp8_scaled): model_root is the
+ # safetensors file itself, so there's no tokenizer/processor folder
+ # alongside it. Fall back to the canonical Qwen2.5-VL repo on HF (small
+ # ~10 MB download for tokenizer+processor configs, cached for offline use).
+ if model_root.is_file():
+ HF_REPO = "Qwen/Qwen2.5-VL-7B-Instruct"
+ try:
+ tokenizer = AutoTokenizer.from_pretrained(HF_REPO, local_files_only=True)
+ except OSError:
+ tokenizer = AutoTokenizer.from_pretrained(HF_REPO)
+ try:
+ image_processor = _ImageProcessorCls.from_pretrained(HF_REPO, local_files_only=True)
+ except OSError:
+ try:
+ image_processor = _ImageProcessorCls.from_pretrained(HF_REPO)
+ except Exception:
+ image_processor = _ImageProcessorCls()
+ else:
+ tokenizer_dir = model_root / "tokenizer"
+ tokenizer = AutoTokenizer.from_pretrained(str(tokenizer_dir), local_files_only=True)
+
+ image_processor = None
+ for search_dir in [model_root / "processor", tokenizer_dir, model_root, model_root / "image_processor"]:
+ if (search_dir / "preprocessor_config.json").exists():
+ image_processor = _ImageProcessorCls.from_pretrained(str(search_dir), local_files_only=True)
+ break
+ if image_processor is None:
+ image_processor = _ImageProcessorCls()
+
+ processor = Qwen2_5_VLProcessor(
+ tokenizer=tokenizer,
+ image_processor=image_processor,
+ video_processor=_VideoProcessorCls(),
+ )
+
+ context.util.signal_progress("Running Qwen2.5-VL text/vision encoder")
+
+ if self.quantization != "none":
+ text_encoder, device, cleanup = self._load_quantized_encoder(context)
+ else:
+ text_encoder, device, cleanup = self._load_cached_encoder(context)
+
+ try:
+ model_inputs = processor(
+ text=[text],
+ images=images if images else None,
+ padding=True,
+ return_tensors="pt",
+ ).to(device=device)
+
+ outputs = text_encoder(
+ input_ids=model_inputs.input_ids,
+ attention_mask=model_inputs.attention_mask,
+ pixel_values=getattr(model_inputs, "pixel_values", None),
+ image_grid_thw=getattr(model_inputs, "image_grid_thw", None),
+ output_hidden_states=True,
+ )
+
+ # Use last hidden state (matching diffusers pipeline)
+ hidden_states = outputs.hidden_states[-1]
+
+ # Extract valid (non-padding) tokens using the attention mask,
+ # then drop the system prompt prefix tokens.
+ # The drop index differs between edit mode (64) and generate mode (34).
+ drop_idx = _EDIT_DROP_IDX if images else _GENERATE_DROP_IDX
+
+ attn_mask = model_inputs.attention_mask
+ bool_mask = attn_mask.bool()
+ valid_lengths = bool_mask.sum(dim=1)
+ selected = hidden_states[bool_mask]
+ split_hidden = torch.split(selected, valid_lengths.tolist(), dim=0)
+
+ # Drop system prefix tokens and build padded output
+ trimmed = [h[drop_idx:] for h in split_hidden]
+ attn_mask_list = [torch.ones(h.size(0), dtype=torch.long, device=device) for h in trimmed]
+ max_seq_len = max(h.size(0) for h in trimmed)
+
+ prompt_embeds = torch.stack(
+ [torch.cat([h, h.new_zeros(max_seq_len - h.size(0), h.size(1))]) for h in trimmed]
+ )
+ encoder_attention_mask = torch.stack(
+ [torch.cat([m, m.new_zeros(max_seq_len - m.size(0))]) for m in attn_mask_list]
+ )
+
+ prompt_embeds = prompt_embeds.to(dtype=torch.bfloat16)
+ finally:
+ if cleanup is not None:
+ cleanup()
+
+ # If all tokens are valid (no padding), mask is not needed
+ if encoder_attention_mask.all():
+ encoder_attention_mask = None
+
+ return prompt_embeds, encoder_attention_mask
+
+ def _load_cached_encoder(self, context: InvocationContext):
+ """Load the text encoder through the model cache (no quantization)."""
+ from transformers import Qwen2_5_VLForConditionalGeneration
+
+ text_encoder_info = context.models.load(self.qwen_vl_encoder.text_encoder)
+ ctx = text_encoder_info.model_on_device()
+ _, text_encoder = ctx.__enter__()
+ device = get_effective_device(text_encoder)
+ assert isinstance(text_encoder, Qwen2_5_VLForConditionalGeneration)
+ return text_encoder, device, lambda: ctx.__exit__(None, None, None)
+
+ def _load_quantized_encoder(self, context: InvocationContext):
+ """Load the text encoder with BitsAndBytes quantization, bypassing the model cache.
+
+ BnB-quantized models are pinned to GPU and can't be moved between devices,
+ so they can't go through the standard model cache. The model is loaded fresh
+ each time and freed after use via the cleanup callback.
+ """
+ import gc
+ import warnings
+
+ from transformers import BitsAndBytesConfig, Qwen2_5_VLForConditionalGeneration
+
+ encoder_config = context.models.get_config(self.qwen_vl_encoder.text_encoder)
+ model_root = context.models.get_absolute_path(encoder_config)
+ if model_root.is_file():
+ # Single-file checkpoint (e.g. ComfyUI fp8_scaled): BnB can't load from
+ # a single file, and the checkpoint is already FP8-compressed anyway.
+ # Fall back to the cached path; the user effectively gets fp8 instead of
+ # int8/nf4, which is comparable in size.
+ return self._load_cached_encoder(context)
+ encoder_path = model_root / "text_encoder"
+
+ if self.quantization == "nf4":
+ bnb_config = BitsAndBytesConfig(
+ load_in_4bit=True,
+ bnb_4bit_compute_dtype=torch.bfloat16,
+ bnb_4bit_quant_type="nf4",
+ )
+ else: # int8
+ bnb_config = BitsAndBytesConfig(load_in_8bit=True)
+
+ context.util.signal_progress("Loading Qwen2.5-VL encoder (quantized)")
+ with warnings.catch_warnings():
+ # BnB int8 internally casts bfloat16→float16; the warning is harmless
+ warnings.filterwarnings("ignore", message="MatMul8bitLt.*cast.*float16")
+ text_encoder = Qwen2_5_VLForConditionalGeneration.from_pretrained(
+ str(encoder_path),
+ quantization_config=bnb_config,
+ device_map="auto",
+ torch_dtype=torch.bfloat16,
+ local_files_only=True,
+ )
+
+ device = next(text_encoder.parameters()).device
+
+ def cleanup():
+ nonlocal text_encoder
+ del text_encoder
+ gc.collect()
+ torch.cuda.empty_cache()
+
+ return text_encoder, device, cleanup
diff --git a/invokeai/app/invocations/scheduler.py b/invokeai/app/invocations/scheduler.py
index 52af20378ef..a870a442ef8 100644
--- a/invokeai/app/invocations/scheduler.py
+++ b/invokeai/app/invocations/scheduler.py
@@ -1,5 +1,4 @@
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
-from invokeai.app.invocations.constants import SCHEDULER_NAME_VALUES
from invokeai.app.invocations.fields import (
FieldDescriptions,
InputField,
@@ -7,6 +6,7 @@
UIType,
)
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES
@invocation_output("scheduler_output")
diff --git a/invokeai/app/invocations/sd3_denoise.py b/invokeai/app/invocations/sd3_denoise.py
new file mode 100644
index 00000000000..10c9080ac5e
--- /dev/null
+++ b/invokeai/app/invocations/sd3_denoise.py
@@ -0,0 +1,354 @@
+from typing import Callable, Optional, Tuple
+
+import torch
+import torchvision.transforms as tv_transforms
+from diffusers.models.transformers.transformer_sd3 import SD3Transformer2DModel
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ SD3ConditioningField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import TransformerField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.invocations.sd3_text_encoder import SD3_T5_MAX_SEQ_LEN
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional
+from invokeai.backend.model_manager.taxonomy import BaseModelType
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SD3ConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "sd3_denoise",
+ title="Denoise - SD3",
+ tags=["image", "sd3"],
+ category="latents",
+ version="1.2.0",
+)
+class SD3DenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Run denoising process with a SD3 model."""
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.latents, input=Input.Connection
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.noise, input=Input.Connection
+ )
+ # denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
+ )
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.sd3_model, input=Input.Connection, title="Transformer"
+ )
+ positive_conditioning: SD3ConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: SD3ConditioningField = InputField(
+ description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ cfg_scale: float | list[float] = InputField(default=3.5, description=FieldDescriptions.cfg_scale, title="CFG Scale")
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ steps: int = InputField(default=10, gt=0, description=FieldDescriptions.steps)
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ """Prepare the inpaint mask.
+ - Loads the mask
+ - Resizes if necessary
+ - Casts to same device/dtype as latents
+
+ Args:
+ context (InvocationContext): The invocation context, for loading the inpaint mask.
+ latents (torch.Tensor): A latent image tensor. Used to determine the target shape, device, and dtype for the
+ inpaint mask.
+
+ Returns:
+ torch.Tensor | None: Inpaint mask. Values of 0.0 represent the regions to be fully denoised, and 1.0
+ represent the regions to be preserved.
+ """
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+
+ # The input denoise_mask contains values in [0, 1], where 0.0 represents the regions to be fully denoised, and
+ # 1.0 represents the regions to be preserved.
+ # We invert the mask so that the regions to be preserved are 0.0 and the regions to be denoised are 1.0.
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask
+
+ def _load_text_conditioning(
+ self,
+ context: InvocationContext,
+ conditioning_name: str,
+ joint_attention_dim: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ # Load the conditioning data.
+ cond_data = context.conditioning.load(conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ sd3_conditioning = cond_data.conditionings[0]
+ assert isinstance(sd3_conditioning, SD3ConditioningInfo)
+ sd3_conditioning = sd3_conditioning.to(dtype=dtype, device=device)
+
+ t5_embeds = sd3_conditioning.t5_embeds
+ if t5_embeds is None:
+ t5_embeds = torch.zeros(
+ (1, SD3_T5_MAX_SEQ_LEN, joint_attention_dim),
+ device=device,
+ dtype=dtype,
+ )
+
+ clip_prompt_embeds = torch.cat([sd3_conditioning.clip_l_embeds, sd3_conditioning.clip_g_embeds], dim=-1)
+ clip_prompt_embeds = torch.nn.functional.pad(
+ clip_prompt_embeds, (0, t5_embeds.shape[-1] - clip_prompt_embeds.shape[-1])
+ )
+
+ prompt_embeds = torch.cat([clip_prompt_embeds, t5_embeds], dim=-2)
+ pooled_prompt_embeds = torch.cat(
+ [sd3_conditioning.clip_l_pooled_embeds, sd3_conditioning.clip_g_pooled_embeds], dim=-1
+ )
+
+ return prompt_embeds, pooled_prompt_embeds
+
+ def _get_noise(
+ self,
+ num_samples: int,
+ num_channels_latents: int,
+ height: int,
+ width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ seed: int,
+ ) -> torch.Tensor:
+ # We always generate noise on the same device and dtype then cast to ensure consistency across devices/dtypes.
+ rand_device = "cpu"
+ rand_dtype = torch.float16
+
+ return torch.randn(
+ num_samples,
+ num_channels_latents,
+ int(height) // LATENT_SCALE_FACTOR,
+ int(width) // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+ def _prepare_cfg_scale(self, num_timesteps: int) -> list[float]:
+ """Prepare the CFG scale list.
+
+ Args:
+ num_timesteps (int): The number of timesteps in the scheduler. Could be different from num_steps depending
+ on the scheduler used (e.g. higher order schedulers).
+
+ Returns:
+ list[float]: _description_
+ """
+ if isinstance(self.cfg_scale, float):
+ cfg_scale = [self.cfg_scale] * num_timesteps
+ elif isinstance(self.cfg_scale, list):
+ assert len(self.cfg_scale) == num_timesteps
+ cfg_scale = self.cfg_scale
+ else:
+ raise ValueError(f"Invalid CFG scale type: {type(self.cfg_scale)}")
+
+ return cfg_scale
+
+ def _run_diffusion(
+ self,
+ context: InvocationContext,
+ ):
+ inference_dtype = TorchDevice.choose_torch_dtype()
+ device = TorchDevice.choose_torch_device()
+
+ transformer_info = context.models.load(self.transformer.transformer)
+
+ # Load/process the conditioning data.
+ # TODO(ryand): Make CFG optional.
+ do_classifier_free_guidance = True
+ pos_prompt_embeds, pos_pooled_prompt_embeds = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.positive_conditioning.conditioning_name,
+ joint_attention_dim=transformer_info.model.config.joint_attention_dim,
+ dtype=inference_dtype,
+ device=device,
+ )
+ neg_prompt_embeds, neg_pooled_prompt_embeds = self._load_text_conditioning(
+ context=context,
+ conditioning_name=self.negative_conditioning.conditioning_name,
+ joint_attention_dim=transformer_info.model.config.joint_attention_dim,
+ dtype=inference_dtype,
+ device=device,
+ )
+ # TODO(ryand): Support both sequential and batched CFG inference.
+ prompt_embeds = torch.cat([neg_prompt_embeds, pos_prompt_embeds], dim=0)
+ pooled_prompt_embeds = torch.cat([neg_pooled_prompt_embeds, pos_pooled_prompt_embeds], dim=0)
+
+ # Prepare the timestep schedule.
+ # We add an extra step to the end to account for the final timestep of 0.0.
+ timesteps: list[float] = torch.linspace(1, 0, self.steps + 1).tolist()
+ # Clip the timesteps schedule based on denoising_start and denoising_end.
+ timesteps = clip_timestep_schedule_fractional(timesteps, self.denoising_start, self.denoising_end)
+ total_steps = len(timesteps) - 1
+
+ # Prepare the CFG scale list.
+ cfg_scale = self._prepare_cfg_scale(total_steps)
+
+ # Load the input latents, if provided.
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+
+ # Generate initial latent noise.
+ num_channels_latents = transformer_info.model.config.in_channels
+ assert isinstance(num_channels_latents, int)
+ noise = self._prepare_noise_tensor(context, num_channels_latents, inference_dtype, device)
+
+ # Prepare input latent image.
+ if init_latents is not None:
+ # Noise the init_latents by the appropriate amount for the first timestep.
+ t_0 = timesteps[0]
+ latents = t_0 * noise + (1.0 - t_0) * init_latents
+ else:
+ # init_latents are not provided, so we are not doing image-to-image (i.e. we are starting from pure noise).
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ latents = noise
+
+ # If len(timesteps) == 1, then short-circuit. We are just noising the input latents, but not taking any
+ # denoising steps.
+ if len(timesteps) <= 1:
+ return latents
+
+ # Prepare inpaint extension.
+ inpaint_mask = self._prep_inpaint_mask(context, latents)
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ assert init_latents is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents,
+ inpaint_mask=inpaint_mask,
+ noise=noise,
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=0,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(timesteps[0]),
+ latents=latents,
+ ),
+ )
+
+ with transformer_info.model_on_device() as (cached_weights, transformer):
+ assert isinstance(transformer, SD3Transformer2DModel)
+
+ # 6. Denoising loop
+ for step_idx, (t_curr, t_prev) in tqdm(
+ list(enumerate(zip(timesteps[:-1], timesteps[1:], strict=True))),
+ desc=f"Denoising{TorchDevice.get_session_device_label()}",
+ ):
+ # Expand the latents if we are doing CFG.
+ latent_model_input = torch.cat([latents] * 2) if do_classifier_free_guidance else latents
+ # Expand the timestep to match the latent model input.
+ # Multiply by 1000 to match the default FlowMatchEulerDiscreteScheduler num_train_timesteps.
+ timestep = torch.tensor([t_curr * 1000], device=device).expand(latent_model_input.shape[0])
+
+ noise_pred = transformer(
+ hidden_states=latent_model_input,
+ timestep=timestep,
+ encoder_hidden_states=prompt_embeds,
+ pooled_projections=pooled_prompt_embeds,
+ joint_attention_kwargs=None,
+ return_dict=False,
+ )[0]
+
+ # Apply CFG.
+ if do_classifier_free_guidance:
+ noise_pred_uncond, noise_pred_cond = noise_pred.chunk(2)
+ noise_pred = noise_pred_uncond + cfg_scale[step_idx] * (noise_pred_cond - noise_pred_uncond)
+
+ # Compute the previous noisy sample x_t -> x_t-1.
+ latents_dtype = latents.dtype
+ latents = latents.to(dtype=torch.float32)
+ latents = latents + (t_prev - t_curr) * noise_pred
+ latents = latents.to(dtype=latents_dtype)
+
+ if inpaint_extension is not None:
+ latents = inpaint_extension.merge_intermediate_latents_with_init_latents(latents, t_prev)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(t_curr),
+ latents=latents,
+ ),
+ )
+
+ return latents
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, num_channels_latents: int, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "SD3", self.width, self.height)
+ return noise
+
+ return self._get_noise(
+ num_samples=1,
+ num_channels_latents=num_channels_latents,
+ height=self.height,
+ width=self.width,
+ dtype=inference_dtype,
+ device=device,
+ seed=self.seed,
+ )
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.StableDiffusion3)
+
+ return step_callback
diff --git a/invokeai/app/invocations/sd3_image_to_latents.py b/invokeai/app/invocations/sd3_image_to_latents.py
new file mode 100644
index 00000000000..9af641d8bcf
--- /dev/null
+++ b/invokeai/app/invocations/sd3_image_to_latents.py
@@ -0,0 +1,72 @@
+import einops
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_sd3
+
+
+@invocation(
+ "sd3_i2l",
+ title="Image to Latents - SD3",
+ tags=["image", "latents", "vae", "i2l", "sd3"],
+ category="latents",
+ version="1.0.1",
+)
+class SD3ImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates latents from an image."""
+
+ image: ImageField = InputField(description="The image to encode")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ assert isinstance(vae_info.model, AutoencoderKL)
+ estimated_working_memory = estimate_vae_working_memory_sd3(
+ operation="encode", image_tensor=image_tensor, vae=vae_info.model
+ )
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ assert isinstance(vae, AutoencoderKL)
+
+ vae.disable_tiling()
+
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae.dtype)
+ with torch.inference_mode():
+ image_tensor_dist = vae.encode(image_tensor).latent_dist
+ # TODO: Use seed to make sampling reproducible.
+ latents: torch.Tensor = image_tensor_dist.sample().to(dtype=vae.dtype)
+
+ latents = vae.config.scaling_factor * latents
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, AutoencoderKL)
+
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/sd3_latents_to_image.py b/invokeai/app/invocations/sd3_latents_to_image.py
new file mode 100644
index 00000000000..e6a20d38a9c
--- /dev/null
+++ b/invokeai/app/invocations/sd3_latents_to_image.py
@@ -0,0 +1,81 @@
+from contextlib import nullcontext
+
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_sd3
+
+
+@invocation(
+ "sd3_l2i",
+ title="Latents to Image - SD3",
+ tags=["latents", "image", "vae", "l2i", "sd3"],
+ category="latents",
+ version="1.3.2",
+)
+class SD3LatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents."""
+
+ latents: LatentsField = InputField(
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ vae: VAEField = InputField(
+ description=FieldDescriptions.vae,
+ input=Input.Connection,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ assert isinstance(vae_info.model, (AutoencoderKL))
+ estimated_working_memory = estimate_vae_working_memory_sd3(
+ operation="decode", image_tensor=latents, vae=vae_info.model
+ )
+ with (
+ SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes),
+ vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae),
+ ):
+ context.util.signal_progress("Running VAE")
+ assert isinstance(vae, (AutoencoderKL))
+ latents = latents.to(TorchDevice.choose_torch_device())
+
+ vae.disable_tiling()
+
+ tiling_context = nullcontext()
+
+ # clear memory as vae decode can request a lot
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode(), tiling_context:
+ # copied from diffusers pipeline
+ latents = latents / vae.config.scaling_factor
+ img = vae.decode(latents, return_dict=False)[0]
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c") # noqa: F821
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/sd3_model_loader.py b/invokeai/app/invocations/sd3_model_loader.py
new file mode 100644
index 00000000000..7d095d96c6b
--- /dev/null
+++ b/invokeai/app/invocations/sd3_model_loader.py
@@ -0,0 +1,109 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.app.util.t5_model_identifier import (
+ preprocess_t5_encoder_model_identifier,
+ preprocess_t5_tokenizer_model_identifier,
+)
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ClipVariantType, ModelType, SubModelType
+
+
+@invocation_output("sd3_model_loader_output")
+class Sd3ModelLoaderOutput(BaseInvocationOutput):
+ """SD3 base model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ clip_l: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP L")
+ clip_g: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP G")
+ t5_encoder: T5EncoderField = OutputField(description=FieldDescriptions.t5_encoder, title="T5 Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "sd3_model_loader",
+ title="Main Model - SD3",
+ tags=["model", "sd3"],
+ category="model",
+ version="1.0.1",
+)
+class Sd3ModelLoaderInvocation(BaseInvocation):
+ """Loads a SD3 base model, outputting its submodels."""
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.sd3_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.StableDiffusion3,
+ ui_model_type=ModelType.Main,
+ )
+
+ t5_encoder_model: Optional[ModelIdentifierField] = InputField(
+ description=FieldDescriptions.t5_encoder,
+ input=Input.Direct,
+ title="T5 Encoder",
+ default=None,
+ ui_model_type=ModelType.T5Encoder,
+ )
+
+ clip_l_model: Optional[ModelIdentifierField] = InputField(
+ description=FieldDescriptions.clip_embed_model,
+ input=Input.Direct,
+ title="CLIP L Encoder",
+ default=None,
+ ui_model_type=ModelType.CLIPEmbed,
+ ui_model_variant=ClipVariantType.L,
+ )
+
+ clip_g_model: Optional[ModelIdentifierField] = InputField(
+ description=FieldDescriptions.clip_g_model,
+ input=Input.Direct,
+ title="CLIP G Encoder",
+ default=None,
+ ui_model_type=ModelType.CLIPEmbed,
+ ui_model_variant=ClipVariantType.G,
+ )
+
+ vae_model: Optional[ModelIdentifierField] = InputField(
+ description=FieldDescriptions.vae_model,
+ title="VAE",
+ default=None,
+ ui_model_base=BaseModelType.StableDiffusion3,
+ ui_model_type=ModelType.VAE,
+ )
+
+ def invoke(self, context: InvocationContext) -> Sd3ModelLoaderOutput:
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+ vae = (
+ self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ if self.vae_model
+ else self.model.model_copy(update={"submodel_type": SubModelType.VAE})
+ )
+ tokenizer_l = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ clip_encoder_l = (
+ self.clip_l_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ if self.clip_l_model
+ else self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ )
+ tokenizer_g = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer2})
+ clip_encoder_g = (
+ self.clip_g_model.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
+ if self.clip_g_model
+ else self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
+ )
+ tokenizer_t5 = preprocess_t5_tokenizer_model_identifier(self.t5_encoder_model or self.model)
+ t5_encoder = preprocess_t5_encoder_model_identifier(self.t5_encoder_model or self.model)
+
+ return Sd3ModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ clip_l=CLIPField(tokenizer=tokenizer_l, text_encoder=clip_encoder_l, loras=[], skipped_layers=0),
+ clip_g=CLIPField(tokenizer=tokenizer_g, text_encoder=clip_encoder_g, loras=[], skipped_layers=0),
+ t5_encoder=T5EncoderField(tokenizer=tokenizer_t5, text_encoder=t5_encoder, loras=[]),
+ vae=VAEField(vae=vae),
+ )
diff --git a/invokeai/app/invocations/sd3_text_encoder.py b/invokeai/app/invocations/sd3_text_encoder.py
new file mode 100644
index 00000000000..d9f5c3f1f15
--- /dev/null
+++ b/invokeai/app/invocations/sd3_text_encoder.py
@@ -0,0 +1,207 @@
+from contextlib import ExitStack
+from typing import Iterator, Tuple
+
+import torch
+from transformers import (
+ CLIPTextModel,
+ CLIPTextModelWithProjection,
+ CLIPTokenizer,
+ T5EncoderModel,
+ T5Tokenizer,
+ T5TokenizerFast,
+)
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField
+from invokeai.app.invocations.model import CLIPField, T5EncoderField
+from invokeai.app.invocations.primitives import SD3ConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.model_manager.taxonomy import ModelFormat
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_CLIP_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, SD3ConditioningInfo
+
+# The SD3 T5 Max Sequence Length set based on the default in diffusers.
+SD3_T5_MAX_SEQ_LEN = 256
+
+
+@invocation(
+ "sd3_text_encoder",
+ title="Prompt - SD3",
+ tags=["prompt", "conditioning", "sd3"],
+ category="prompt",
+ version="1.0.1",
+ idle_gpu_offloadable=True,
+)
+class Sd3TextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for a SD3 image."""
+
+ clip_l: CLIPField = InputField(
+ title="CLIP L",
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ )
+ clip_g: CLIPField = InputField(
+ title="CLIP G",
+ description=FieldDescriptions.clip,
+ input=Input.Connection,
+ )
+
+ # The SD3 models were trained with text encoder dropout, so the T5 encoder can be omitted to save time/memory.
+ t5_encoder: T5EncoderField | None = InputField(
+ title="T5Encoder",
+ default=None,
+ description=FieldDescriptions.t5_encoder,
+ input=Input.Connection,
+ )
+ prompt: str = InputField(description="Text prompt to encode.")
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> SD3ConditioningOutput:
+ # Note: The text encoding model are run in separate functions to ensure that all model references are locally
+ # scoped. This ensures that earlier models can be freed and gc'd before loading later models (if necessary).
+
+ clip_l_embeddings, clip_l_pooled_embeddings = self._clip_encode(context, self.clip_l)
+ clip_g_embeddings, clip_g_pooled_embeddings = self._clip_encode(context, self.clip_g)
+
+ t5_embeddings: torch.Tensor | None = None
+ if self.t5_encoder is not None:
+ t5_embeddings = self._t5_encode(context, SD3_T5_MAX_SEQ_LEN)
+
+ # Move all embeddings to CPU for storage to save VRAM
+ # They will be moved to the appropriate device when used by the denoiser
+ clip_l_embeddings = clip_l_embeddings.detach().to("cpu")
+ clip_l_pooled_embeddings = clip_l_pooled_embeddings.detach().to("cpu")
+ clip_g_embeddings = clip_g_embeddings.detach().to("cpu")
+ clip_g_pooled_embeddings = clip_g_pooled_embeddings.detach().to("cpu")
+ if t5_embeddings is not None:
+ t5_embeddings = t5_embeddings.detach().to("cpu")
+
+ conditioning_data = ConditioningFieldData(
+ conditionings=[
+ SD3ConditioningInfo(
+ clip_l_embeds=clip_l_embeddings,
+ clip_l_pooled_embeds=clip_l_pooled_embeddings,
+ clip_g_embeds=clip_g_embeddings,
+ clip_g_pooled_embeds=clip_g_pooled_embeddings,
+ t5_embeds=t5_embeddings,
+ )
+ ]
+ )
+
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return SD3ConditioningOutput.build(conditioning_name)
+
+ def _t5_encode(self, context: InvocationContext, max_seq_len: int) -> torch.Tensor:
+ assert self.t5_encoder is not None
+ prompt = [self.prompt]
+
+ with (
+ context.models.load(self.t5_encoder.text_encoder) as t5_text_encoder,
+ context.models.load(self.t5_encoder.tokenizer) as t5_tokenizer,
+ ):
+ context.util.signal_progress("Running T5 encoder")
+ assert isinstance(t5_text_encoder, T5EncoderModel)
+ assert isinstance(t5_tokenizer, (T5Tokenizer, T5TokenizerFast))
+ t5_device = get_effective_device(t5_text_encoder)
+
+ text_inputs = t5_tokenizer(
+ prompt,
+ padding="max_length",
+ max_length=max_seq_len,
+ truncation=True,
+ add_special_tokens=True,
+ return_tensors="pt",
+ )
+ text_input_ids = text_inputs.input_ids
+ untruncated_ids = t5_tokenizer(prompt, padding="longest", return_tensors="pt").input_ids
+ assert isinstance(text_input_ids, torch.Tensor)
+ assert isinstance(untruncated_ids, torch.Tensor)
+ if untruncated_ids.shape[-1] >= text_input_ids.shape[-1] and not torch.equal(
+ text_input_ids, untruncated_ids
+ ):
+ removed_text = t5_tokenizer.batch_decode(untruncated_ids[:, max_seq_len - 1 : -1])
+ context.logger.warning(
+ "The following part of your input was truncated because `max_sequence_length` is set to "
+ f" {max_seq_len} tokens: {removed_text}"
+ )
+
+ prompt_embeds = t5_text_encoder(text_input_ids.to(t5_device))[0]
+
+ assert isinstance(prompt_embeds, torch.Tensor)
+ return prompt_embeds
+
+ def _clip_encode(
+ self, context: InvocationContext, clip_model: CLIPField, tokenizer_max_length: int = 77
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
+ prompt = [self.prompt]
+
+ clip_text_encoder_info = context.models.load(clip_model.text_encoder)
+ with (
+ clip_text_encoder_info.model_on_device() as (cached_weights, clip_text_encoder),
+ context.models.load(clip_model.tokenizer) as clip_tokenizer,
+ ExitStack() as exit_stack,
+ ):
+ context.util.signal_progress("Running CLIP encoder")
+ assert isinstance(clip_text_encoder, (CLIPTextModel, CLIPTextModelWithProjection))
+ assert isinstance(clip_tokenizer, CLIPTokenizer)
+ clip_device = get_effective_device(clip_text_encoder)
+
+ clip_text_encoder_config = clip_text_encoder_info.config
+ assert clip_text_encoder_config is not None
+
+ # Apply LoRA models to the CLIP encoder.
+ # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching.
+ if clip_text_encoder_config.format in [ModelFormat.Diffusers]:
+ # The model is non-quantized, so we can apply the LoRA weights directly into the model.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=clip_text_encoder,
+ patches=self._clip_lora_iterator(context, clip_model),
+ prefix=FLUX_LORA_CLIP_PREFIX,
+ dtype=clip_text_encoder.dtype,
+ cached_weights=cached_weights,
+ )
+ )
+ else:
+ # There are currently no supported CLIP quantized models. Add support here if needed.
+ raise ValueError(f"Unsupported model format: {clip_text_encoder_config.format}")
+
+ clip_text_encoder = clip_text_encoder.eval().requires_grad_(False)
+
+ text_inputs = clip_tokenizer(
+ prompt,
+ padding="max_length",
+ max_length=tokenizer_max_length,
+ truncation=True,
+ return_tensors="pt",
+ )
+
+ text_input_ids = text_inputs.input_ids
+ untruncated_ids = clip_tokenizer(prompt, padding="longest", return_tensors="pt").input_ids
+ assert isinstance(text_input_ids, torch.Tensor)
+ assert isinstance(untruncated_ids, torch.Tensor)
+ if untruncated_ids.shape[-1] >= text_input_ids.shape[-1] and not torch.equal(
+ text_input_ids, untruncated_ids
+ ):
+ removed_text = clip_tokenizer.batch_decode(untruncated_ids[:, tokenizer_max_length - 1 : -1])
+ context.logger.warning(
+ "The following part of your input was truncated because CLIP can only handle sequences up to"
+ f" {tokenizer_max_length} tokens: {removed_text}"
+ )
+ prompt_embeds = clip_text_encoder(input_ids=text_input_ids.to(clip_device), output_hidden_states=True)
+ pooled_prompt_embeds = prompt_embeds[0]
+ prompt_embeds = prompt_embeds.hidden_states[-2]
+
+ return prompt_embeds, pooled_prompt_embeds
+
+ def _clip_lora_iterator(
+ self, context: InvocationContext, clip_model: CLIPField
+ ) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ for lora in clip_model.loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/sdxl.py b/invokeai/app/invocations/sdxl.py
index 1c0817cb928..0f509828c13 100644
--- a/invokeai/app/invocations/sdxl.py
+++ b/invokeai/app/invocations/sdxl.py
@@ -1,14 +1,8 @@
-from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField, UIType
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField
+from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, UNetField, VAEField
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager import SubModelType
-
-from .baseinvocation import (
- BaseInvocation,
- BaseInvocationOutput,
- invocation,
- invocation_output,
-)
-from .model import CLIPField, ModelIdentifierField, UNetField, VAEField
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType
@invocation_output("sdxl_model_loader_output")
@@ -30,12 +24,14 @@ class SDXLRefinerModelLoaderOutput(BaseInvocationOutput):
vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
-@invocation("sdxl_model_loader", title="SDXL Main Model", tags=["model", "sdxl"], category="model", version="1.0.3")
+@invocation("sdxl_model_loader", title="Main Model - SDXL", tags=["model", "sdxl"], category="model", version="1.0.4")
class SDXLModelLoaderInvocation(BaseInvocation):
"""Loads an sdxl base model, outputting its submodels."""
model: ModelIdentifierField = InputField(
- description=FieldDescriptions.sdxl_main_model, ui_type=UIType.SDXLMainModel
+ description=FieldDescriptions.sdxl_main_model,
+ ui_model_base=BaseModelType.StableDiffusionXL,
+ ui_model_type=ModelType.Main,
)
# TODO: precision?
@@ -64,16 +60,18 @@ def invoke(self, context: InvocationContext) -> SDXLModelLoaderOutput:
@invocation(
"sdxl_refiner_model_loader",
- title="SDXL Refiner Model",
+ title="Refiner Model - SDXL",
tags=["model", "sdxl", "refiner"],
category="model",
- version="1.0.3",
+ version="1.0.4",
)
class SDXLRefinerModelLoaderInvocation(BaseInvocation):
"""Loads an sdxl refiner model, outputting its submodels."""
model: ModelIdentifierField = InputField(
- description=FieldDescriptions.sdxl_refiner_model, ui_type=UIType.SDXLRefinerModel
+ description=FieldDescriptions.sdxl_refiner_model,
+ ui_model_base=BaseModelType.StableDiffusionXLRefiner,
+ ui_model_type=ModelType.Main,
)
# TODO: precision?
diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py
new file mode 100644
index 00000000000..35d20e47336
--- /dev/null
+++ b/invokeai/app/invocations/segment_anything.py
@@ -0,0 +1,217 @@
+from itertools import zip_longest
+from pathlib import Path
+from typing import Literal
+
+import numpy as np
+import torch
+from PIL import Image
+from pydantic import BaseModel, Field, model_validator
+from transformers.models.sam import SamModel
+from transformers.models.sam.processing_sam import SamProcessor
+from transformers.models.sam2 import Sam2Model
+from transformers.models.sam2.processing_sam2 import Sam2Processor
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import BoundingBoxField, ImageField, InputField, TensorField
+from invokeai.app.invocations.primitives import MaskOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.image_util.segment_anything.mask_refinement import mask_to_polygon, polygon_to_mask
+from invokeai.backend.image_util.segment_anything.segment_anything_2_pipeline import SegmentAnything2Pipeline
+from invokeai.backend.image_util.segment_anything.segment_anything_pipeline import SegmentAnythingPipeline
+from invokeai.backend.image_util.segment_anything.shared import SAMInput, SAMPoint
+
+SegmentAnythingModelKey = Literal[
+ "segment-anything-base",
+ "segment-anything-large",
+ "segment-anything-huge",
+ "segment-anything-2-tiny",
+ "segment-anything-2-small",
+ "segment-anything-2-base",
+ "segment-anything-2-large",
+]
+SEGMENT_ANYTHING_MODEL_IDS: dict[SegmentAnythingModelKey, str] = {
+ "segment-anything-base": "facebook/sam-vit-base",
+ "segment-anything-large": "facebook/sam-vit-large",
+ "segment-anything-huge": "facebook/sam-vit-huge",
+ "segment-anything-2-tiny": "facebook/sam2.1-hiera-tiny",
+ "segment-anything-2-small": "facebook/sam2.1-hiera-small",
+ "segment-anything-2-base": "facebook/sam2.1-hiera-base-plus",
+ "segment-anything-2-large": "facebook/sam2.1-hiera-large",
+}
+
+
+class SAMPointsField(BaseModel):
+ points: list[SAMPoint] = Field(..., description="The points of the object", min_length=1)
+
+ def to_list(self) -> list[list[float]]:
+ return [[point.x, point.y, point.label.value] for point in self.points]
+
+
+@invocation(
+ "segment_anything",
+ title="Segment Anything",
+ tags=["prompt", "segmentation", "sam", "sam2"],
+ category="segmentation",
+ version="1.3.0",
+)
+class SegmentAnythingInvocation(BaseInvocation):
+ """Runs a Segment Anything Model (SAM or SAM2)."""
+
+ # Reference:
+ # - https://arxiv.org/pdf/2304.02643
+ # - https://huggingface.co/docs/transformers/v4.43.3/en/model_doc/grounding-dino#grounded-sam
+ # - https://github.com/NielsRogge/Transformers-Tutorials/blob/a39f33ac1557b02ebfb191ea7753e332b5ca933f/Grounding%20DINO/GroundingDINO_with_Segment_Anything.ipynb
+
+ model: SegmentAnythingModelKey = InputField(description="The Segment Anything model to use (SAM or SAM2).")
+ image: ImageField = InputField(description="The image to segment.")
+ bounding_boxes: list[BoundingBoxField] | None = InputField(
+ default=None, description="The bounding boxes to prompt the model with."
+ )
+ point_lists: list[SAMPointsField] | None = InputField(
+ default=None,
+ description="The list of point lists to prompt the model with. Each list of points represents a single object.",
+ )
+ apply_polygon_refinement: bool = InputField(
+ description="Whether to apply polygon refinement to the masks. This will smooth the edges of the masks slightly and ensure that each mask consists of a single closed polygon (before merging).",
+ default=True,
+ )
+ mask_filter: Literal["all", "largest", "highest_box_score"] = InputField(
+ description="The filtering to apply to the detected masks before merging them into a final output.",
+ default="all",
+ )
+
+ @model_validator(mode="after")
+ def validate_points_and_boxes_len(self):
+ if self.point_lists is not None and self.bounding_boxes is not None:
+ if len(self.point_lists) != len(self.bounding_boxes):
+ raise ValueError("If both point_lists and bounding_boxes are provided, they must have the same length.")
+ return self
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> MaskOutput:
+ # The models expect a 3-channel RGB image.
+ image_pil = context.images.get_pil(self.image.image_name, mode="RGB")
+
+ if (not self.bounding_boxes or len(self.bounding_boxes) == 0) and (
+ not self.point_lists or len(self.point_lists) == 0
+ ):
+ combined_mask = torch.zeros(image_pil.size[::-1], dtype=torch.bool)
+ else:
+ masks = self._segment(context=context, image=image_pil)
+ masks = self._filter_masks(masks=masks, bounding_boxes=self.bounding_boxes)
+
+ # masks contains bool values, so we merge them via max-reduce.
+ combined_mask, _ = torch.stack(masks).max(dim=0)
+
+ # Unsqueeze the channel dimension.
+ combined_mask = combined_mask.unsqueeze(0)
+ mask_tensor_name = context.tensors.save(combined_mask)
+ _, height, width = combined_mask.shape
+ return MaskOutput(mask=TensorField(tensor_name=mask_tensor_name), width=width, height=height)
+
+ @staticmethod
+ def _load_sam_model(model_path: Path):
+ sam_model = SamModel.from_pretrained(
+ model_path,
+ local_files_only=True,
+ # TODO(ryand): Setting the torch_dtype here doesn't work. Investigate whether fp16 is supported by the
+ # model, and figure out how to make it work in the pipeline.
+ # torch_dtype=TorchDevice.choose_torch_dtype(),
+ )
+ sam_processor = SamProcessor.from_pretrained(model_path, local_files_only=True)
+ return SegmentAnythingPipeline(sam_model=sam_model, sam_processor=sam_processor)
+
+ @staticmethod
+ def _load_sam_2_model(model_path: Path):
+ sam2_model = Sam2Model.from_pretrained(model_path, local_files_only=True)
+ sam2_processor = Sam2Processor.from_pretrained(model_path, local_files_only=True)
+ return SegmentAnything2Pipeline(sam2_model=sam2_model, sam2_processor=sam2_processor)
+
+ def _segment(self, context: InvocationContext, image: Image.Image) -> list[torch.Tensor]:
+ """Use Segment Anything (SAM or SAM2) to generate masks given an image + a set of bounding boxes."""
+
+ source = SEGMENT_ANYTHING_MODEL_IDS[self.model]
+ inputs: list[SAMInput] = []
+ for bbox_field, point_field in zip_longest(self.bounding_boxes or [], self.point_lists or [], fillvalue=None):
+ inputs.append(
+ SAMInput(
+ bounding_box=bbox_field,
+ points=point_field.points if point_field else None,
+ )
+ )
+
+ if "sam2" in source:
+ loader = SegmentAnythingInvocation._load_sam_2_model
+ with context.models.load_remote_model(source=source, loader=loader) as pipeline:
+ assert isinstance(pipeline, SegmentAnything2Pipeline)
+ masks = pipeline.segment(image=image, inputs=inputs)
+ else:
+ loader = SegmentAnythingInvocation._load_sam_model
+ with context.models.load_remote_model(source=source, loader=loader) as pipeline:
+ assert isinstance(pipeline, SegmentAnythingPipeline)
+ masks = pipeline.segment(image=image, inputs=inputs)
+
+ masks = self._process_masks(masks)
+ if self.apply_polygon_refinement:
+ masks = self._apply_polygon_refinement(masks)
+
+ return masks
+
+ def _process_masks(self, masks: torch.Tensor) -> list[torch.Tensor]:
+ """Convert the tensor output from the Segment Anything model from a tensor of shape
+ [num_masks, channels, height, width] to a list of tensors of shape [height, width].
+ """
+ assert masks.dtype == torch.bool
+ # [num_masks, channels, height, width] -> [num_masks, height, width]
+ masks, _ = masks.max(dim=1)
+ # Split the first dimension into a list of masks.
+ return list(masks.cpu().unbind(dim=0))
+
+ def _apply_polygon_refinement(self, masks: list[torch.Tensor]) -> list[torch.Tensor]:
+ """Apply polygon refinement to the masks.
+
+ Convert each mask to a polygon, then back to a mask. This has the following effect:
+ - Smooth the edges of the mask slightly.
+ - Ensure that each mask consists of a single closed polygon
+ - Removes small mask pieces.
+ - Removes holes from the mask.
+ """
+ # Convert tensor masks to np masks.
+ np_masks = [mask.cpu().numpy().astype(np.uint8) for mask in masks]
+
+ # Apply polygon refinement.
+ for idx, mask in enumerate(np_masks):
+ shape = mask.shape
+ assert len(shape) == 2 # Assert length to satisfy type checker.
+ polygon = mask_to_polygon(mask)
+ mask = polygon_to_mask(polygon, shape)
+ np_masks[idx] = mask
+
+ # Convert np masks back to tensor masks.
+ masks = [torch.tensor(mask, dtype=torch.bool) for mask in np_masks]
+
+ return masks
+
+ def _filter_masks(
+ self, masks: list[torch.Tensor], bounding_boxes: list[BoundingBoxField] | None
+ ) -> list[torch.Tensor]:
+ """Filter the detected masks based on the specified mask filter."""
+
+ if self.mask_filter == "all":
+ return masks
+ elif self.mask_filter == "largest":
+ # Find the largest mask.
+ return [max(masks, key=lambda x: float(x.sum()))]
+ elif self.mask_filter == "highest_box_score":
+ assert bounding_boxes is not None, (
+ "Bounding boxes must be provided to use the 'highest_box_score' mask filter."
+ )
+ assert len(masks) == len(bounding_boxes)
+ # Find the index of the bounding box with the highest score.
+ # Note that we fallback to -1.0 if the score is None. This is mainly to satisfy the type checker. In most
+ # cases the scores should all be non-None when using this filtering mode. That being said, -1.0 is a
+ # reasonable fallback since the expected score range is [0.0, 1.0].
+ max_score_idx = max(range(len(bounding_boxes)), key=lambda i: bounding_boxes[i].score or -1.0)
+ return [masks[max_score_idx]]
+ else:
+ raise ValueError(f"Invalid mask filter: {self.mask_filter}")
diff --git a/invokeai/app/invocations/spandrel_image_to_image.py b/invokeai/app/invocations/spandrel_image_to_image.py
new file mode 100644
index 00000000000..fb870c429c4
--- /dev/null
+++ b/invokeai/app/invocations/spandrel_image_to_image.py
@@ -0,0 +1,291 @@
+import functools
+from typing import Callable
+
+import numpy as np
+import torch
+from PIL import Image
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.session_processor.session_processor_common import CanceledException
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import ModelType
+from invokeai.backend.spandrel_image_to_image_model import SpandrelImageToImageModel
+from invokeai.backend.tiles.tiles import calc_tiles_min_overlap
+from invokeai.backend.tiles.utils import TBLR, Tile
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation("spandrel_image_to_image", title="Image-to-Image", tags=["upscale"], category="upscale", version="1.3.0")
+class SpandrelImageToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Run any spandrel image-to-image model (https://github.com/chaiNNer-org/spandrel)."""
+
+ image: ImageField = InputField(description="The input image")
+ image_to_image_model: ModelIdentifierField = InputField(
+ title="Image-to-Image Model",
+ description=FieldDescriptions.spandrel_image_to_image_model,
+ ui_model_type=ModelType.SpandrelImageToImage,
+ )
+ tile_size: int = InputField(
+ default=512, description="The tile size for tiled image-to-image. Set to 0 to disable tiling."
+ )
+
+ @classmethod
+ def scale_tile(cls, tile: Tile, scale: int) -> Tile:
+ return Tile(
+ coords=TBLR(
+ top=tile.coords.top * scale,
+ bottom=tile.coords.bottom * scale,
+ left=tile.coords.left * scale,
+ right=tile.coords.right * scale,
+ ),
+ overlap=TBLR(
+ top=tile.overlap.top * scale,
+ bottom=tile.overlap.bottom * scale,
+ left=tile.overlap.left * scale,
+ right=tile.overlap.right * scale,
+ ),
+ )
+
+ @classmethod
+ def upscale_image(
+ cls,
+ image: Image.Image,
+ tile_size: int,
+ spandrel_model: SpandrelImageToImageModel,
+ is_canceled: Callable[[], bool],
+ step_callback: Callable[[int, int], None],
+ ) -> Image.Image:
+ # Compute the image tiles.
+ if tile_size > 0:
+ min_overlap = 20
+ tiles = calc_tiles_min_overlap(
+ image_height=image.height,
+ image_width=image.width,
+ tile_height=tile_size,
+ tile_width=tile_size,
+ min_overlap=min_overlap,
+ )
+ else:
+ # No tiling. Generate a single tile that covers the entire image.
+ min_overlap = 0
+ tiles = [
+ Tile(
+ coords=TBLR(top=0, bottom=image.height, left=0, right=image.width),
+ overlap=TBLR(top=0, bottom=0, left=0, right=0),
+ )
+ ]
+
+ # Sort tiles first by left x coordinate, then by top y coordinate. During tile processing, we want to iterate
+ # over tiles left-to-right, top-to-bottom.
+ tiles = sorted(tiles, key=lambda x: x.coords.left)
+ tiles = sorted(tiles, key=lambda x: x.coords.top)
+
+ # Prepare input image for inference.
+ image_tensor = SpandrelImageToImageModel.pil_to_tensor(image)
+
+ # Scale the tiles for re-assembling the final image.
+ scale = spandrel_model.scale
+ scaled_tiles = [cls.scale_tile(tile, scale=scale) for tile in tiles]
+
+ # Prepare the output tensor.
+ _, channels, height, width = image_tensor.shape
+ output_tensor = torch.zeros(
+ (height * scale, width * scale, channels), dtype=torch.uint8, device=torch.device("cpu")
+ )
+
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=spandrel_model.dtype)
+
+ # Run the model on each tile.
+ pbar = tqdm(list(zip(tiles, scaled_tiles, strict=True)), desc="Upscaling Tiles")
+
+ # Update progress, starting with 0.
+ step_callback(0, pbar.total)
+
+ for tile, scaled_tile in pbar:
+ # Exit early if the invocation has been canceled.
+ if is_canceled():
+ raise CanceledException
+
+ # Extract the current tile from the input tensor.
+ input_tile = image_tensor[:, :, tile.coords.top : tile.coords.bottom, tile.coords.left : tile.coords.right]
+
+ # Run the model on the tile.
+ output_tile = spandrel_model.run(input_tile)
+
+ # Convert the output tile into the output tensor's format.
+ # (N, C, H, W) -> (C, H, W)
+ output_tile = output_tile.squeeze(0)
+ # (C, H, W) -> (H, W, C)
+ output_tile = output_tile.permute(1, 2, 0)
+ output_tile = output_tile.clamp(0, 1)
+ output_tile = (output_tile * 255).to(dtype=torch.uint8, device=torch.device("cpu"))
+
+ # Merge the output tile into the output tensor.
+ # We only keep half of the overlap on the top and left side of the tile. We do this in case there are
+ # edge artifacts. We don't bother with any 'blending' in the current implementation - for most upscalers
+ # it seems unnecessary, but we may find a need in the future.
+ top_overlap = scaled_tile.overlap.top // 2
+ left_overlap = scaled_tile.overlap.left // 2
+ output_tensor[
+ scaled_tile.coords.top + top_overlap : scaled_tile.coords.bottom,
+ scaled_tile.coords.left + left_overlap : scaled_tile.coords.right,
+ :,
+ ] = output_tile[top_overlap:, left_overlap:, :]
+
+ step_callback(pbar.n + 1, pbar.total)
+
+ # Convert the output tensor to a PIL image.
+ np_image = output_tensor.detach().numpy().astype(np.uint8)
+ pil_image = Image.fromarray(np_image)
+
+ return pil_image
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ # Images are converted to RGB, because most models don't support an alpha channel. In the future, we may want to
+ # revisit this.
+ image = context.images.get_pil(self.image.image_name, mode="RGB")
+
+ def step_callback(step: int, total_steps: int) -> None:
+ context.util.signal_progress(
+ message=f"Processing tile {step}/{total_steps}",
+ percentage=step / total_steps,
+ )
+
+ # Do the upscaling.
+ with context.models.load(self.image_to_image_model) as spandrel_model:
+ assert isinstance(spandrel_model, SpandrelImageToImageModel)
+
+ # Upscale the image
+ pil_image = self.upscale_image(
+ image, self.tile_size, spandrel_model, context.util.is_canceled, step_callback
+ )
+
+ image_dto = context.images.save(image=pil_image)
+ return ImageOutput.build(image_dto)
+
+
+@invocation(
+ "spandrel_image_to_image_autoscale",
+ title="Image-to-Image (Autoscale)",
+ tags=["upscale"],
+ category="upscale",
+ version="1.0.0",
+)
+class SpandrelImageToImageAutoscaleInvocation(SpandrelImageToImageInvocation):
+ """Run any spandrel image-to-image model (https://github.com/chaiNNer-org/spandrel) until the target scale is reached."""
+
+ scale: float = InputField(
+ default=4.0,
+ gt=0.0,
+ le=16.0,
+ description="The final scale of the output image. If the model does not upscale the image, this will be ignored.",
+ )
+ fit_to_multiple_of_8: bool = InputField(
+ default=False,
+ description="If true, the output image will be resized to the nearest multiple of 8 in both dimensions.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ # Images are converted to RGB, because most models don't support an alpha channel. In the future, we may want to
+ # revisit this.
+ image = context.images.get_pil(self.image.image_name, mode="RGB")
+
+ # The target size of the image, determined by the provided scale. We'll run the upscaler until we hit this size.
+ # Later, we may mutate this value if the model doesn't upscale the image or if the user requested a multiple of 8.
+ target_width = int(image.width * self.scale)
+ target_height = int(image.height * self.scale)
+
+ def step_callback(iteration: int, step: int, total_steps: int) -> None:
+ context.util.signal_progress(
+ message=self._get_progress_message(iteration, step, total_steps),
+ percentage=step / total_steps,
+ )
+
+ # Do the upscaling.
+ with context.models.load(self.image_to_image_model) as spandrel_model:
+ assert isinstance(spandrel_model, SpandrelImageToImageModel)
+
+ iteration = 1
+ context.util.signal_progress(self._get_progress_message(iteration))
+
+ # First pass of upscaling. Note: `pil_image` will be mutated.
+ pil_image = self.upscale_image(
+ image,
+ self.tile_size,
+ spandrel_model,
+ context.util.is_canceled,
+ functools.partial(step_callback, iteration),
+ )
+
+ # Some models don't upscale the image, but we have no way to know this in advance. We'll check if the model
+ # upscaled the image and run the loop below if it did. We'll require the model to upscale both dimensions
+ # to be considered an upscale model.
+ is_upscale_model = pil_image.width > image.width and pil_image.height > image.height
+
+ if is_upscale_model:
+ # This is an upscale model, so we should keep upscaling until we reach the target size.
+ while pil_image.width < target_width or pil_image.height < target_height:
+ iteration += 1
+ context.util.signal_progress(self._get_progress_message(iteration))
+ pil_image = self.upscale_image(
+ pil_image,
+ self.tile_size,
+ spandrel_model,
+ context.util.is_canceled,
+ functools.partial(step_callback, iteration),
+ )
+
+ # Sanity check to prevent excessive or infinite loops. All known upscaling models are at least 2x.
+ # Our max scale is 16x, so with a 2x model, we should never exceed 16x == 2^4 -> 4 iterations.
+ # We'll allow one extra iteration "just in case" and bail at 5 upscaling iterations. In practice,
+ # we should never reach this limit.
+ if iteration >= 5:
+ context.logger.warning(
+ "Upscale loop reached maximum iteration count of 5, stopping upscaling early."
+ )
+ break
+ else:
+ # This model doesn't upscale the image. We should ignore the scale parameter, modifying the output size
+ # to be the same as the processed image size.
+
+ # The output size is now the size of the processed image.
+ target_width = pil_image.width
+ target_height = pil_image.height
+
+ # Warn the user if they requested a scale greater than 1.
+ if self.scale > 1:
+ context.logger.warning(
+ "Model does not increase the size of the image, but a greater scale than 1 was requested. Image will not be scaled."
+ )
+
+ # We may need to resize the image to a multiple of 8. Use floor division to ensure we don't scale the image up
+ # in the final resize
+ if self.fit_to_multiple_of_8:
+ target_width = int(target_width // 8 * 8)
+ target_height = int(target_height // 8 * 8)
+
+ # Final resize. Per PIL documentation, Lanczos provides the best quality for both upscale and downscale.
+ # See: https://pillow.readthedocs.io/en/stable/handbook/concepts.html#filters-comparison-table
+ pil_image = pil_image.resize((target_width, target_height), resample=Image.Resampling.LANCZOS)
+
+ image_dto = context.images.save(image=pil_image)
+ return ImageOutput.build(image_dto)
+
+ @classmethod
+ def _get_progress_message(cls, iteration: int, step: int | None = None, total_steps: int | None = None) -> str:
+ if step is not None and total_steps is not None:
+ return f"Processing iteration {iteration}, tile {step}/{total_steps}"
+
+ return f"Processing iteration {iteration}"
diff --git a/invokeai/app/invocations/strings.py b/invokeai/app/invocations/strings.py
index 46ef35cbbfa..6d64e8771ad 100644
--- a/invokeai/app/invocations/strings.py
+++ b/invokeai/app/invocations/strings.py
@@ -2,17 +2,11 @@
import re
+from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
+from invokeai.app.invocations.fields import InputField, OutputField, UIComponent
+from invokeai.app.invocations.primitives import StringOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
-from .baseinvocation import (
- BaseInvocation,
- BaseInvocationOutput,
- invocation,
- invocation_output,
-)
-from .fields import InputField, OutputField, UIComponent
-from .primitives import StringOutput
-
@invocation_output("string_pos_neg_output")
class StringPosNegOutput(BaseInvocationOutput):
@@ -26,7 +20,7 @@ class StringPosNegOutput(BaseInvocationOutput):
"string_split_neg",
title="String Split Negative",
tags=["string", "split", "negative"],
- category="string",
+ category="strings",
version="1.0.1",
)
class StringSplitNegInvocation(BaseInvocation):
@@ -69,7 +63,7 @@ class String2Output(BaseInvocationOutput):
string_2: str = OutputField(description="string 2")
-@invocation("string_split", title="String Split", tags=["string", "split"], category="string", version="1.0.1")
+@invocation("string_split", title="String Split", tags=["string", "split"], category="strings", version="1.0.1")
class StringSplitInvocation(BaseInvocation):
"""Splits string into two strings, based on the first occurance of the delimiter. The delimiter will be removed from the string"""
@@ -89,7 +83,7 @@ def invoke(self, context: InvocationContext) -> String2Output:
return String2Output(string_1=part1, string_2=part2)
-@invocation("string_join", title="String Join", tags=["string", "join"], category="string", version="1.0.1")
+@invocation("string_join", title="String Join", tags=["string", "join"], category="strings", version="1.0.1")
class StringJoinInvocation(BaseInvocation):
"""Joins string left to string right"""
@@ -100,7 +94,9 @@ def invoke(self, context: InvocationContext) -> StringOutput:
return StringOutput(value=((self.string_left or "") + (self.string_right or "")))
-@invocation("string_join_three", title="String Join Three", tags=["string", "join"], category="string", version="1.0.1")
+@invocation(
+ "string_join_three", title="String Join Three", tags=["string", "join"], category="strings", version="1.0.1"
+)
class StringJoinThreeInvocation(BaseInvocation):
"""Joins string left to string middle to string right"""
@@ -113,7 +109,7 @@ def invoke(self, context: InvocationContext) -> StringOutput:
@invocation(
- "string_replace", title="String Replace", tags=["string", "replace", "regex"], category="string", version="1.0.1"
+ "string_replace", title="String Replace", tags=["string", "replace", "regex"], category="strings", version="1.0.1"
)
class StringReplaceInvocation(BaseInvocation):
"""Replaces the search string with the replace string"""
diff --git a/invokeai/app/invocations/t2i_adapter.py b/invokeai/app/invocations/t2i_adapter.py
index 04f9a6c6954..cf4b7cda474 100644
--- a/invokeai/app/invocations/t2i_adapter.py
+++ b/invokeai/app/invocations/t2i_adapter.py
@@ -8,11 +8,12 @@
invocation,
invocation_output,
)
-from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, OutputField, UIType
+from invokeai.app.invocations.fields import FieldDescriptions, ImageField, InputField, OutputField
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.util import validate_begin_end_step, validate_weights
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
class T2IAdapterField(BaseModel):
@@ -45,7 +46,11 @@ class T2IAdapterOutput(BaseInvocationOutput):
@invocation(
- "t2i_adapter", title="T2I-Adapter", tags=["t2i_adapter", "control"], category="t2i_adapter", version="1.0.3"
+ "t2i_adapter",
+ title="T2I-Adapter - SD1.5, SDXL",
+ tags=["t2i_adapter", "control"],
+ category="conditioning",
+ version="1.0.4",
)
class T2IAdapterInvocation(BaseInvocation):
"""Collects T2I-Adapter info to pass to other nodes."""
@@ -56,7 +61,8 @@ class T2IAdapterInvocation(BaseInvocation):
description="The T2I-Adapter model.",
title="T2I-Adapter Model",
ui_order=-1,
- ui_type=UIType.T2IAdapterModel,
+ ui_model_base=[BaseModelType.StableDiffusion1, BaseModelType.StableDiffusionXL],
+ ui_model_type=ModelType.T2IAdapter,
)
weight: Union[float, list[float]] = InputField(
default=1, ge=0, description="The weight given to the T2I-Adapter", title="Weight"
diff --git a/invokeai/app/invocations/text_llm.py b/invokeai/app/invocations/text_llm.py
new file mode 100644
index 00000000000..789e65be018
--- /dev/null
+++ b/invokeai/app/invocations/text_llm.py
@@ -0,0 +1,65 @@
+import torch
+from transformers import AutoTokenizer
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import FieldDescriptions, InputField, UIComponent
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.invocations.primitives import StringOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import ModelType
+from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline
+from invokeai.backend.util.devices import TorchDevice
+
+
+@invocation(
+ "text_llm",
+ title="Text LLM",
+ tags=["llm", "text", "prompt"],
+ category="llm",
+ version="1.0.0",
+ classification=Classification.Beta,
+)
+class TextLLMInvocation(BaseInvocation):
+ """Run a text language model to generate or expand text (e.g. for prompt expansion)."""
+
+ prompt: str = InputField(
+ default="",
+ description="Input text prompt.",
+ ui_component=UIComponent.Textarea,
+ )
+ system_prompt: str = InputField(
+ default=DEFAULT_SYSTEM_PROMPT,
+ description="System prompt that guides the model's behavior.",
+ ui_component=UIComponent.Textarea,
+ )
+ text_llm_model: ModelIdentifierField = InputField(
+ title="Text LLM Model",
+ description=FieldDescriptions.text_llm_model,
+ ui_model_type=ModelType.TextLLM,
+ )
+ max_tokens: int = InputField(
+ default=300,
+ ge=1,
+ le=2048,
+ description="Maximum number of tokens to generate.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> StringOutput:
+ model_config = context.models.get_config(self.text_llm_model)
+
+ with context.models.load(self.text_llm_model).model_on_device() as (_, model):
+ model_abs_path = context.models.get_absolute_path(model_config)
+ tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True)
+
+ pipeline = TextLLMPipeline(model, tokenizer)
+ model_device = next(model.parameters()).device
+ output = pipeline.run(
+ prompt=self.prompt,
+ system_prompt=self.system_prompt,
+ max_new_tokens=self.max_tokens,
+ device=model_device,
+ dtype=TorchDevice.choose_torch_dtype(),
+ )
+
+ return StringOutput(value=output)
diff --git a/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py b/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py
new file mode 100644
index 00000000000..80067b8c931
--- /dev/null
+++ b/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py
@@ -0,0 +1,292 @@
+import copy
+from contextlib import ExitStack
+from typing import Iterator, Tuple
+
+import torch
+from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel
+from diffusers.schedulers.scheduling_utils import SchedulerMixin
+from pydantic import field_validator
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.controlnet import ControlField
+from invokeai.app.invocations.denoise_latents import DenoiseLatentsInvocation, get_scheduler
+from invokeai.app.invocations.fields import (
+ ConditioningField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ UIType,
+)
+from invokeai.app.invocations.model import UNetField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusers_pipeline import ControlNetData, PipelineIntermediateState
+from invokeai.backend.stable_diffusion.multi_diffusion_pipeline import (
+ MultiDiffusionPipeline,
+ MultiDiffusionRegionConditioning,
+)
+from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES
+from invokeai.backend.tiles.tiles import (
+ calc_tiles_min_overlap,
+)
+from invokeai.backend.tiles.utils import TBLR
+from invokeai.backend.util.devices import TorchDevice
+
+
+def crop_controlnet_data(control_data: ControlNetData, latent_region: TBLR) -> ControlNetData:
+ """Crop a ControlNetData object to a region."""
+ # Create a shallow copy of the control_data object.
+ control_data_copy = copy.copy(control_data)
+ # The ControlNet reference image is the only attribute that needs to be cropped.
+ control_data_copy.image_tensor = control_data.image_tensor[
+ :,
+ :,
+ latent_region.top * LATENT_SCALE_FACTOR : latent_region.bottom * LATENT_SCALE_FACTOR,
+ latent_region.left * LATENT_SCALE_FACTOR : latent_region.right * LATENT_SCALE_FACTOR,
+ ]
+ return control_data_copy
+
+
+@invocation(
+ "tiled_multi_diffusion_denoise_latents",
+ title="Tiled Multi-Diffusion Denoise - SD1.5, SDXL",
+ tags=["upscale", "denoise"],
+ category="latents",
+ version="1.0.1",
+)
+class TiledMultiDiffusionDenoiseLatents(BaseInvocation):
+ """Tiled Multi-Diffusion denoising.
+
+ This node handles automatically tiling the input image, and is primarily intended for global refinement of images
+ in tiled upscaling workflows. Future Multi-Diffusion nodes should allow the user to specify custom regions with
+ different parameters for each region to harness the full power of Multi-Diffusion.
+
+ This node has a similar interface to the `DenoiseLatents` node, but it has a reduced feature set (no IP-Adapter,
+ T2I-Adapter, masking, etc.).
+ """
+
+ positive_conditioning: ConditioningField = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: ConditioningField = InputField(
+ description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ noise: LatentsField | None = InputField(
+ default=None,
+ description=FieldDescriptions.noise,
+ input=Input.Connection,
+ )
+ latents: LatentsField | None = InputField(
+ default=None,
+ description=FieldDescriptions.latents,
+ input=Input.Connection,
+ )
+ tile_height: int = InputField(
+ default=1024, gt=0, multiple_of=LATENT_SCALE_FACTOR, description="Height of the tiles in image space."
+ )
+ tile_width: int = InputField(
+ default=1024, gt=0, multiple_of=LATENT_SCALE_FACTOR, description="Width of the tiles in image space."
+ )
+ tile_overlap: int = InputField(
+ default=32,
+ multiple_of=LATENT_SCALE_FACTOR,
+ gt=0,
+ description="The overlap between adjacent tiles in pixel space. (Of course, tile merging is applied in latent "
+ "space.) Tiles will be cropped during merging (if necessary) to ensure that they overlap by exactly this "
+ "amount.",
+ )
+ steps: int = InputField(default=18, gt=0, description=FieldDescriptions.steps)
+ cfg_scale: float | list[float] = InputField(default=6.0, description=FieldDescriptions.cfg_scale, title="CFG Scale")
+ denoising_start: float = InputField(
+ default=0.0,
+ ge=0,
+ le=1,
+ description=FieldDescriptions.denoising_start,
+ )
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ scheduler: SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description=FieldDescriptions.scheduler,
+ ui_type=UIType.Scheduler,
+ )
+ unet: UNetField = InputField(
+ description=FieldDescriptions.unet,
+ input=Input.Connection,
+ title="UNet",
+ )
+ cfg_rescale_multiplier: float = InputField(
+ title="CFG Rescale Multiplier", default=0, ge=0, lt=1, description=FieldDescriptions.cfg_rescale_multiplier
+ )
+ control: ControlField | list[ControlField] | None = InputField(
+ default=None,
+ input=Input.Connection,
+ )
+
+ @field_validator("cfg_scale")
+ def ge_one(cls, v: list[float] | float) -> list[float] | float:
+ """Validate that all cfg_scale values are >= 1"""
+ if isinstance(v, list):
+ for i in v:
+ if i < 1:
+ raise ValueError("cfg_scale must be greater than 1")
+ else:
+ if v < 1:
+ raise ValueError("cfg_scale must be greater than 1")
+ return v
+
+ @staticmethod
+ def create_pipeline(
+ unet: UNet2DConditionModel,
+ scheduler: SchedulerMixin,
+ ) -> MultiDiffusionPipeline:
+ # TODO(ryand): Get rid of this FakeVae hack.
+ class FakeVae:
+ class FakeVaeConfig:
+ def __init__(self) -> None:
+ self.block_out_channels = [0]
+
+ def __init__(self) -> None:
+ self.config = FakeVae.FakeVaeConfig()
+
+ return MultiDiffusionPipeline(
+ vae=FakeVae(),
+ text_encoder=None,
+ tokenizer=None,
+ unet=unet,
+ scheduler=scheduler,
+ safety_checker=None,
+ feature_extractor=None,
+ requires_safety_checker=False,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ # Convert tile image-space dimensions to latent-space dimensions.
+ latent_tile_height = self.tile_height // LATENT_SCALE_FACTOR
+ latent_tile_width = self.tile_width // LATENT_SCALE_FACTOR
+ latent_tile_overlap = self.tile_overlap // LATENT_SCALE_FACTOR
+
+ seed, noise, latents = DenoiseLatentsInvocation.prepare_noise_and_latents(context, self.noise, self.latents)
+ _, _, latent_height, latent_width = latents.shape
+
+ # Calculate the tile locations to cover the latent-space image.
+ # TODO(ryand): In the future, we may want to revisit the tile overlap strategy. Things to consider:
+ # - How much overlap 'context' to provide for each denoising step.
+ # - How much overlap to use during merging/blending.
+ # - Should we 'jitter' the tile locations in each step so that the seams are in different places?
+ tiles = calc_tiles_min_overlap(
+ image_height=latent_height,
+ image_width=latent_width,
+ tile_height=latent_tile_height,
+ tile_width=latent_tile_width,
+ min_overlap=latent_tile_overlap,
+ )
+
+ # Get the unet's config so that we can pass the base to sd_step_callback().
+ unet_config = context.models.get_config(self.unet.unet.key)
+
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, unet_config.base)
+
+ # Prepare an iterator that yields the UNet's LoRA models and their weights.
+ def _lora_loader() -> Iterator[Tuple[ModelPatchRaw, float]]:
+ for lora in self.unet.loras:
+ lora_info = context.models.load(lora.lora)
+ assert isinstance(lora_info.model, ModelPatchRaw)
+ yield (lora_info.model, lora.weight)
+ del lora_info
+
+ device = TorchDevice.choose_torch_device()
+ with (
+ ExitStack() as exit_stack,
+ context.models.load(self.unet.unet) as unet,
+ LayerPatcher.apply_smart_model_patches(
+ model=unet, patches=_lora_loader(), prefix="lora_unet_", dtype=unet.dtype
+ ),
+ ):
+ assert isinstance(unet, UNet2DConditionModel)
+ latents = latents.to(device=device, dtype=unet.dtype)
+ if noise is not None:
+ noise = noise.to(device=device, dtype=unet.dtype)
+ scheduler = get_scheduler(
+ context=context,
+ scheduler_info=self.unet.scheduler,
+ scheduler_name=self.scheduler,
+ seed=seed,
+ unet_config=unet_config,
+ )
+ pipeline = self.create_pipeline(unet=unet, scheduler=scheduler)
+
+ # Prepare the prompt conditioning data. The same prompt conditioning is applied to all tiles.
+ conditioning_data = DenoiseLatentsInvocation.get_conditioning_data(
+ context=context,
+ positive_conditioning_field=self.positive_conditioning,
+ negative_conditioning_field=self.negative_conditioning,
+ device=device,
+ dtype=unet.dtype,
+ latent_height=latent_tile_height,
+ latent_width=latent_tile_width,
+ cfg_scale=self.cfg_scale,
+ steps=self.steps,
+ cfg_rescale_multiplier=self.cfg_rescale_multiplier,
+ )
+
+ controlnet_data = DenoiseLatentsInvocation.prep_control_data(
+ context=context,
+ control_input=self.control,
+ latents_shape=list(latents.shape),
+ device=device,
+ # do_classifier_free_guidance=(self.cfg_scale >= 1.0))
+ do_classifier_free_guidance=True,
+ exit_stack=exit_stack,
+ )
+
+ # Split the controlnet_data into tiles.
+ # controlnet_data_tiles[t][c] is the c'th control data for the t'th tile.
+ controlnet_data_tiles: list[list[ControlNetData]] = []
+ for tile in tiles:
+ tile_controlnet_data = [crop_controlnet_data(cn, tile.coords) for cn in controlnet_data or []]
+ controlnet_data_tiles.append(tile_controlnet_data)
+
+ # Prepare the MultiDiffusionRegionConditioning list.
+ multi_diffusion_conditioning: list[MultiDiffusionRegionConditioning] = []
+ for tile, tile_controlnet_data in zip(tiles, controlnet_data_tiles, strict=True):
+ multi_diffusion_conditioning.append(
+ MultiDiffusionRegionConditioning(
+ region=tile,
+ text_conditioning_data=conditioning_data,
+ control_data=tile_controlnet_data,
+ )
+ )
+
+ timesteps, init_timestep, scheduler_step_kwargs = DenoiseLatentsInvocation.init_scheduler(
+ scheduler,
+ device=device,
+ steps=self.steps,
+ denoising_start=self.denoising_start,
+ denoising_end=self.denoising_end,
+ seed=seed,
+ )
+
+ # Run Multi-Diffusion denoising.
+ result_latents = pipeline.multi_diffusion_denoise(
+ multi_diffusion_conditioning=multi_diffusion_conditioning,
+ target_overlap=latent_tile_overlap,
+ latents=latents,
+ scheduler_step_kwargs=scheduler_step_kwargs,
+ noise=noise,
+ timesteps=timesteps,
+ init_timestep=init_timestep,
+ callback=step_callback,
+ )
+
+ result_latents = result_latents.to("cpu")
+ # TODO(ryand): I copied this from DenoiseLatentsInvocation. I'm not sure if it's actually important.
+ TorchDevice.empty_cache()
+
+ name = context.tensors.save(tensor=result_latents)
+ return LatentsOutput.build(latents_name=name, latents=result_latents, seed=None)
diff --git a/invokeai/app/invocations/tiles.py b/invokeai/app/invocations/tiles.py
index a54001a0a79..a631f3ba4ec 100644
--- a/invokeai/app/invocations/tiles.py
+++ b/invokeai/app/invocations/tiles.py
@@ -7,7 +7,6 @@
from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
BaseInvocationOutput,
- Classification,
invocation,
invocation_output,
)
@@ -40,7 +39,6 @@ class CalculateImageTilesOutput(BaseInvocationOutput):
tags=["tiles"],
category="tiles",
version="1.0.1",
- classification=Classification.Beta,
)
class CalculateImageTilesInvocation(BaseInvocation):
"""Calculate the coordinates and overlaps of tiles that cover a target image shape."""
@@ -74,7 +72,6 @@ def invoke(self, context: InvocationContext) -> CalculateImageTilesOutput:
tags=["tiles"],
category="tiles",
version="1.1.1",
- classification=Classification.Beta,
)
class CalculateImageTilesEvenSplitInvocation(BaseInvocation):
"""Calculate the coordinates and overlaps of tiles that cover a target image shape."""
@@ -117,7 +114,6 @@ def invoke(self, context: InvocationContext) -> CalculateImageTilesOutput:
tags=["tiles"],
category="tiles",
version="1.0.1",
- classification=Classification.Beta,
)
class CalculateImageTilesMinimumOverlapInvocation(BaseInvocation):
"""Calculate the coordinates and overlaps of tiles that cover a target image shape."""
@@ -168,7 +164,6 @@ class TileToPropertiesOutput(BaseInvocationOutput):
tags=["tiles"],
category="tiles",
version="1.0.1",
- classification=Classification.Beta,
)
class TileToPropertiesInvocation(BaseInvocation):
"""Split a Tile into its individual properties."""
@@ -201,7 +196,6 @@ class PairTileImageOutput(BaseInvocationOutput):
tags=["tiles"],
category="tiles",
version="1.0.1",
- classification=Classification.Beta,
)
class PairTileImageInvocation(BaseInvocation):
"""Pair an image with its tile properties."""
@@ -230,7 +224,6 @@ def invoke(self, context: InvocationContext) -> PairTileImageOutput:
tags=["tiles"],
category="tiles",
version="1.1.1",
- classification=Classification.Beta,
)
class MergeTilesToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Merge multiple tile images into a single image."""
diff --git a/invokeai/app/invocations/upscale.py b/invokeai/app/invocations/upscale.py
index f93060f8d34..64e372a0f6b 100644
--- a/invokeai/app/invocations/upscale.py
+++ b/invokeai/app/invocations/upscale.py
@@ -6,15 +6,13 @@
from PIL import Image
from pydantic import ConfigDict
-from invokeai.app.invocations.fields import ImageField
+from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation
+from invokeai.app.invocations.fields import ImageField, InputField, WithBoard, WithMetadata
from invokeai.app.invocations.primitives import ImageOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.image_util.basicsr.rrdbnet_arch import RRDBNet
from invokeai.backend.image_util.realesrgan.realesrgan import RealESRGAN
-from .baseinvocation import BaseInvocation, invocation
-from .fields import InputField, WithBoard, WithMetadata
-
# TODO: Populate this from disk?
# TODO: Use model manager to load?
ESRGAN_MODELS = Literal[
@@ -32,7 +30,7 @@
}
-@invocation("esrgan", title="Upscale (RealESRGAN)", tags=["esrgan", "upscale"], category="esrgan", version="1.3.2")
+@invocation("esrgan", title="Upscale (RealESRGAN)", tags=["esrgan", "upscale"], category="upscale", version="1.3.2")
class ESRGANInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Upscales an image using RealESRGAN."""
diff --git a/invokeai/app/invocations/util.py b/invokeai/app/invocations/util.py
index c69c32eed01..3ae3e17ae67 100644
--- a/invokeai/app/invocations/util.py
+++ b/invokeai/app/invocations/util.py
@@ -9,6 +9,6 @@ def validate_weights(weights: Union[float, list[float]]) -> None:
def validate_begin_end_step(begin_step_percent: float, end_step_percent: float) -> None:
- """Validate that begin_step_percent is less than end_step_percent"""
- if begin_step_percent >= end_step_percent:
+ """Validate that begin_step_percent is less than or equal to end_step_percent"""
+ if begin_step_percent > end_step_percent:
raise ValueError("Begin step percent must be less than or equal to end step percent")
diff --git a/invokeai/app/invocations/z_image_control.py b/invokeai/app/invocations/z_image_control.py
new file mode 100644
index 00000000000..f51c2fcd168
--- /dev/null
+++ b/invokeai/app/invocations/z_image_control.py
@@ -0,0 +1,112 @@
+# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team
+"""Z-Image Control invocation for spatial conditioning."""
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ InputField,
+ OutputField,
+)
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+class ZImageControlField(BaseModel):
+ """A Z-Image control conditioning field for spatial control (Canny, HED, Depth, Pose, MLSD)."""
+
+ image_name: str = Field(description="The name of the preprocessed control image")
+ control_model: ModelIdentifierField = Field(description="The Z-Image ControlNet adapter model")
+ control_context_scale: float = Field(
+ default=0.75,
+ ge=0.0,
+ le=2.0,
+ description="The strength of the control signal. Recommended range: 0.65-0.80.",
+ )
+ begin_step_percent: float = Field(
+ default=0.0,
+ ge=0.0,
+ le=1.0,
+ description="When the control is first applied (% of total steps)",
+ )
+ end_step_percent: float = Field(
+ default=1.0,
+ ge=0.0,
+ le=1.0,
+ description="When the control is last applied (% of total steps)",
+ )
+
+
+@invocation_output("z_image_control_output")
+class ZImageControlOutput(BaseInvocationOutput):
+ """Z-Image Control output containing control configuration."""
+
+ control: ZImageControlField = OutputField(description="Z-Image control conditioning")
+
+
+@invocation(
+ "z_image_control",
+ title="Z-Image ControlNet",
+ tags=["image", "z-image", "control", "controlnet"],
+ category="conditioning",
+ version="1.1.0",
+ classification=Classification.Prototype,
+)
+class ZImageControlInvocation(BaseInvocation):
+ """Configure Z-Image ControlNet for spatial conditioning.
+
+ Takes a preprocessed control image (e.g., Canny edges, depth map, pose)
+ and a Z-Image ControlNet adapter model to enable spatial control.
+
+ Supports 5 control modes: Canny, HED, Depth, Pose, MLSD.
+ Recommended control_context_scale: 0.65-0.80.
+ """
+
+ image: ImageField = InputField(
+ description="The preprocessed control image (Canny, HED, Depth, Pose, or MLSD)",
+ )
+ control_model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.controlnet_model,
+ title="Control Model",
+ ui_model_base=BaseModelType.ZImage,
+ ui_model_type=ModelType.ControlNet,
+ )
+ control_context_scale: float = InputField(
+ default=0.75,
+ ge=0.0,
+ le=2.0,
+ description="Strength of the control signal. Recommended range: 0.65-0.80.",
+ title="Control Scale",
+ )
+ begin_step_percent: float = InputField(
+ default=0.0,
+ ge=0.0,
+ le=1.0,
+ description="When the control is first applied (% of total steps)",
+ )
+ end_step_percent: float = InputField(
+ default=1.0,
+ ge=0.0,
+ le=1.0,
+ description="When the control is last applied (% of total steps)",
+ )
+
+ def invoke(self, context: InvocationContext) -> ZImageControlOutput:
+ return ZImageControlOutput(
+ control=ZImageControlField(
+ image_name=self.image.image_name,
+ control_model=self.control_model,
+ control_context_scale=self.control_context_scale,
+ begin_step_percent=self.begin_step_percent,
+ end_step_percent=self.end_step_percent,
+ )
+ )
diff --git a/invokeai/app/invocations/z_image_denoise.py b/invokeai/app/invocations/z_image_denoise.py
new file mode 100644
index 00000000000..50b41f6121e
--- /dev/null
+++ b/invokeai/app/invocations/z_image_denoise.py
@@ -0,0 +1,813 @@
+import inspect
+import math
+from contextlib import ExitStack
+from typing import Callable, Iterator, Optional, Tuple
+
+import einops
+import torch
+import torchvision.transforms as tv_transforms
+from diffusers.schedulers.scheduling_utils import SchedulerMixin
+from PIL import Image
+from torchvision.transforms.functional import resize as tv_resize
+from tqdm import tqdm
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
+from invokeai.app.invocations.fields import (
+ DenoiseMaskField,
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ ZImageConditioningField,
+)
+from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape
+from invokeai.app.invocations.model import TransformerField, VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.invocations.z_image_control import ZImageControlField
+from invokeai.app.invocations.z_image_image_to_latents import ZImageImageToLatentsInvocation
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.schedulers import ZIMAGE_SCHEDULER_LABELS, ZIMAGE_SCHEDULER_MAP, ZIMAGE_SCHEDULER_NAME_VALUES
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.z_image_lora_constants import Z_IMAGE_LORA_TRANSFORMER_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ZImageConditioningInfo
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.z_image.extensions.regional_prompting_extension import ZImageRegionalPromptingExtension
+from invokeai.backend.z_image.text_conditioning import ZImageTextConditioning
+from invokeai.backend.z_image.z_image_control_adapter import ZImageControlAdapter
+from invokeai.backend.z_image.z_image_controlnet_extension import (
+ ZImageControlNetExtension,
+ z_image_forward_with_control,
+)
+from invokeai.backend.z_image.z_image_transformer_patch import patch_transformer_for_regional_prompting
+
+
+@invocation(
+ "z_image_denoise",
+ title="Denoise - Z-Image",
+ tags=["image", "z-image"],
+ category="latents",
+ version="1.6.0",
+ classification=Classification.Prototype,
+)
+class ZImageDenoiseInvocation(BaseInvocation):
+ """Run the denoising process with a Z-Image model.
+
+ Supports regional prompting by connecting multiple conditioning inputs with masks.
+ """
+
+ # If latents is provided, this means we are doing image-to-image.
+ latents: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.latents, input=Input.Connection
+ )
+ noise: Optional[LatentsField] = InputField(
+ default=None, description=FieldDescriptions.noise, input=Input.Connection
+ )
+ # denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
+ denoise_mask: Optional[DenoiseMaskField] = InputField(
+ default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
+ )
+ denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
+ denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
+ add_noise: bool = InputField(default=True, description="Add noise based on denoising start.")
+ transformer: TransformerField = InputField(
+ description=FieldDescriptions.z_image_model, input=Input.Connection, title="Transformer"
+ )
+ positive_conditioning: ZImageConditioningField | list[ZImageConditioningField] = InputField(
+ description=FieldDescriptions.positive_cond, input=Input.Connection
+ )
+ negative_conditioning: ZImageConditioningField | list[ZImageConditioningField] | None = InputField(
+ default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
+ )
+ # Z-Image-Turbo works best without CFG (guidance_scale=1.0)
+ guidance_scale: float = InputField(
+ default=1.0,
+ ge=1.0,
+ description="Guidance scale for classifier-free guidance. 1.0 = no CFG (recommended for Z-Image-Turbo). "
+ "Values > 1.0 amplify guidance.",
+ title="Guidance Scale",
+ )
+ width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
+ height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
+ # Z-Image-Turbo uses 8 steps by default
+ steps: int = InputField(default=8, gt=0, description="Number of denoising steps. 8 recommended for Z-Image-Turbo.")
+ seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
+ # Z-Image Control support
+ control: Optional[ZImageControlField] = InputField(
+ default=None,
+ description="Z-Image control conditioning for spatial control (Canny, HED, Depth, Pose, MLSD).",
+ input=Input.Connection,
+ )
+ # VAE for encoding control images (required when using control)
+ vae: Optional[VAEField] = InputField(
+ default=None,
+ description=FieldDescriptions.vae + " Required for control conditioning.",
+ input=Input.Connection,
+ )
+ # Shift override for the sigma schedule. If None, shift is auto-calculated from image dimensions.
+ shift: Optional[float] = InputField(
+ default=None,
+ ge=0.0,
+ description="Override the timestep shift (mu) for the sigma schedule. "
+ "Leave blank to auto-calculate based on image dimensions (recommended). "
+ "Lower values (~0.5) produce less noise shifting, higher values (~1.15) produce more.",
+ title="Shift",
+ )
+ # Scheduler selection for the denoising process
+ scheduler: ZIMAGE_SCHEDULER_NAME_VALUES = InputField(
+ default="euler",
+ description="Scheduler (sampler) for the denoising process. Euler is the default and recommended. "
+ "Heun is 2nd-order (better quality, 2x slower). LCM works with Turbo only (not Base).",
+ ui_choice_labels=ZIMAGE_SCHEDULER_LABELS,
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ latents = self._run_diffusion(context)
+ latents = latents.detach().to("cpu")
+
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
+
+ def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
+ """Prepare the inpaint mask."""
+ if self.denoise_mask is None:
+ return None
+ mask = context.tensors.load(self.denoise_mask.mask_name)
+
+ # Invert mask: 0.0 = regions to denoise, 1.0 = regions to preserve
+ mask = 1.0 - mask
+
+ _, _, latent_height, latent_width = latents.shape
+ mask = tv_resize(
+ img=mask,
+ size=[latent_height, latent_width],
+ interpolation=tv_transforms.InterpolationMode.BILINEAR,
+ antialias=False,
+ )
+
+ mask = mask.to(device=latents.device, dtype=latents.dtype)
+ return mask
+
+ def _load_text_conditioning(
+ self,
+ context: InvocationContext,
+ cond_field: ZImageConditioningField | list[ZImageConditioningField],
+ img_height: int,
+ img_width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> list[ZImageTextConditioning]:
+ """Load Z-Image text conditioning with optional regional masks.
+
+ Args:
+ context: The invocation context.
+ cond_field: Single conditioning field or list of fields.
+ img_height: Height of the image token grid (H // patch_size).
+ img_width: Width of the image token grid (W // patch_size).
+ dtype: Target dtype.
+ device: Target device.
+
+ Returns:
+ List of ZImageTextConditioning objects with embeddings and masks.
+ """
+ # Normalize to a list
+ cond_list = [cond_field] if isinstance(cond_field, ZImageConditioningField) else cond_field
+
+ text_conditionings: list[ZImageTextConditioning] = []
+ for cond in cond_list:
+ # Load the text embeddings
+ cond_data = context.conditioning.load(cond.conditioning_name)
+ assert len(cond_data.conditionings) == 1
+ z_image_conditioning = cond_data.conditionings[0]
+ assert isinstance(z_image_conditioning, ZImageConditioningInfo)
+ z_image_conditioning = z_image_conditioning.to(dtype=dtype, device=device)
+ prompt_embeds = z_image_conditioning.prompt_embeds
+
+ # Load the mask, if provided
+ mask: torch.Tensor | None = None
+ if cond.mask is not None:
+ mask = context.tensors.load(cond.mask.tensor_name)
+ mask = mask.to(device=device)
+ mask = ZImageRegionalPromptingExtension.preprocess_regional_prompt_mask(
+ mask, img_height, img_width, dtype, device
+ )
+
+ text_conditionings.append(ZImageTextConditioning(prompt_embeds=prompt_embeds, mask=mask))
+
+ return text_conditionings
+
+ def _get_noise(
+ self,
+ batch_size: int,
+ num_channels_latents: int,
+ height: int,
+ width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ seed: int,
+ ) -> torch.Tensor:
+ """Generate initial noise tensor."""
+ # Generate noise as float32 on CPU for maximum compatibility,
+ # then cast to target dtype/device
+ rand_device = "cpu"
+ rand_dtype = torch.float32
+
+ return torch.randn(
+ batch_size,
+ num_channels_latents,
+ int(height) // LATENT_SCALE_FACTOR,
+ int(width) // LATENT_SCALE_FACTOR,
+ device=rand_device,
+ dtype=rand_dtype,
+ generator=torch.Generator(device=rand_device).manual_seed(seed),
+ ).to(device=device, dtype=dtype)
+
+ def _calculate_shift(
+ self,
+ image_seq_len: int,
+ base_image_seq_len: int = 256,
+ max_image_seq_len: int = 4096,
+ base_shift: float = 0.5,
+ max_shift: float = 1.15,
+ ) -> float:
+ """Calculate timestep shift based on image sequence length.
+
+ Based on diffusers ZImagePipeline.calculate_shift method.
+ Returns a linear shift value (exp(mu) from the original formula).
+ """
+ import math
+
+ m = (max_shift - base_shift) / (max_image_seq_len - base_image_seq_len)
+ b = base_shift - m * base_image_seq_len
+ mu = image_seq_len * m + b
+ # Convert from exponential mu to linear shift value
+ return math.exp(mu)
+
+ def _get_sigmas(self, shift: float, num_steps: int) -> list[float]:
+ """Generate sigma schedule with linear time shift.
+
+ Uses linear time shift: shift / (shift + (1/t - 1)).
+ The shift value is used directly as a multiplier.
+ Generates num_steps + 1 sigma values (including terminal 0.0).
+ """
+
+ def time_shift(shift: float, t: float) -> float:
+ """Apply linear time shift to a single timestep value."""
+ if t <= 0:
+ return 0.0
+ if t >= 1:
+ return 1.0
+ return shift / (shift + (1 / t - 1))
+
+ sigmas = []
+ for i in range(num_steps + 1):
+ t = 1.0 - i / num_steps # Goes from 1.0 to 0.0
+ sigma = time_shift(shift, t)
+ sigmas.append(sigma)
+
+ return sigmas
+
+ def _run_diffusion(self, context: InvocationContext) -> torch.Tensor:
+ device = TorchDevice.choose_torch_device()
+ inference_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+
+ transformer_info = context.models.load(self.transformer.transformer)
+
+ # Calculate image token grid dimensions
+ patch_size = 2 # Z-Image uses patch_size=2
+ latent_height = self.height // LATENT_SCALE_FACTOR
+ latent_width = self.width // LATENT_SCALE_FACTOR
+ img_token_height = latent_height // patch_size
+ img_token_width = latent_width // patch_size
+ img_seq_len = img_token_height * img_token_width
+
+ # Load positive conditioning with regional masks
+ pos_text_conditionings = self._load_text_conditioning(
+ context=context,
+ cond_field=self.positive_conditioning,
+ img_height=img_token_height,
+ img_width=img_token_width,
+ dtype=inference_dtype,
+ device=device,
+ )
+
+ # Create regional prompting extension
+ regional_extension = ZImageRegionalPromptingExtension.from_text_conditionings(
+ text_conditionings=pos_text_conditionings,
+ img_seq_len=img_seq_len,
+ )
+
+ # Get the concatenated prompt embeddings for the transformer
+ pos_prompt_embeds = regional_extension.regional_text_conditioning.prompt_embeds
+
+ # Load negative conditioning if provided and guidance_scale != 1.0
+ # CFG formula: pred = pred_uncond + cfg_scale * (pred_cond - pred_uncond)
+ # At cfg_scale=1.0: pred = pred_cond (no effect, skip uncond computation)
+ # This matches FLUX's convention where 1.0 means "no CFG"
+ neg_prompt_embeds: torch.Tensor | None = None
+ do_classifier_free_guidance = (
+ not math.isclose(self.guidance_scale, 1.0) and self.negative_conditioning is not None
+ )
+ if do_classifier_free_guidance:
+ assert self.negative_conditioning is not None
+ # Load all negative conditionings and concatenate embeddings
+ # Note: We ignore masks for negative conditioning as regional negative prompting is not fully supported
+ neg_text_conditionings = self._load_text_conditioning(
+ context=context,
+ cond_field=self.negative_conditioning,
+ img_height=img_token_height,
+ img_width=img_token_width,
+ dtype=inference_dtype,
+ device=device,
+ )
+ # Concatenate all negative embeddings
+ neg_prompt_embeds = torch.cat([tc.prompt_embeds for tc in neg_text_conditionings], dim=0)
+
+ # Calculate shift based on image sequence length, or use override
+ if self.shift is not None:
+ shift = self.shift
+ else:
+ shift = self._calculate_shift(img_seq_len)
+
+ # Generate sigma schedule with time shift
+ sigmas = self._get_sigmas(shift, self.steps)
+
+ # Apply denoising_start and denoising_end clipping
+ if self.denoising_start > 0 or self.denoising_end < 1:
+ # Calculate start and end indices based on denoising range
+ total_sigmas = len(sigmas)
+ start_idx = int(self.denoising_start * (total_sigmas - 1))
+ end_idx = int(self.denoising_end * (total_sigmas - 1)) + 1
+ sigmas = sigmas[start_idx:end_idx]
+
+ total_steps = len(sigmas) - 1
+
+ # Load input latents if provided (image-to-image)
+ init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
+ if init_latents is not None:
+ init_latents = init_latents.to(device=device, dtype=inference_dtype)
+
+ # Generate initial noise.
+ # If noise will never be consumed, avoid validating/loading it.
+ should_ignore_noise = init_latents is not None and not self.add_noise and self.denoise_mask is None
+ noise: torch.Tensor | None
+ if should_ignore_noise:
+ noise = None
+ else:
+ noise = self._prepare_noise_tensor(context, inference_dtype, device)
+
+ # Prepare input latent image
+ if init_latents is not None:
+ if self.add_noise:
+ assert noise is not None
+ # Noise the init latents using the first sigma from the clipped
+ # InvokeAI schedule.
+ #
+ # Known limitation: if the selected scheduler later starts from a
+ # different first effective sigma/timestep than sigmas[0], the
+ # img2img preblend below may not match that scheduler exactly.
+ # This is an existing pipeline limitation and affects both
+ # internally generated noise and externally supplied noise.
+ s_0 = sigmas[0]
+ latents = s_0 * noise + (1.0 - s_0) * init_latents
+ else:
+ latents = init_latents
+ else:
+ if self.denoising_start > 1e-5:
+ raise ValueError("denoising_start should be 0 when initial latents are not provided.")
+ assert noise is not None
+ latents = noise
+
+ # Short-circuit if no denoising steps
+ if total_steps <= 0:
+ return latents
+
+ # Prepare inpaint extension
+ inpaint_mask = self._prep_inpaint_mask(context, latents)
+ inpaint_extension: RectifiedFlowInpaintExtension | None = None
+ if inpaint_mask is not None:
+ if init_latents is None:
+ raise ValueError("Initial latents are required when using an inpaint mask (image-to-image inpainting)")
+ assert noise is not None
+ inpaint_extension = RectifiedFlowInpaintExtension(
+ init_latents=init_latents,
+ inpaint_mask=inpaint_mask,
+ noise=noise,
+ )
+
+ step_callback = self._build_step_callback(context)
+
+ # Initialize the diffusers scheduler if not using built-in Euler
+ scheduler: SchedulerMixin | None = None
+ use_scheduler = self.scheduler != "euler"
+
+ if use_scheduler:
+ scheduler_class = ZIMAGE_SCHEDULER_MAP[self.scheduler]
+ scheduler = scheduler_class(
+ num_train_timesteps=1000,
+ shift=1.0,
+ )
+ # Set timesteps - LCM uses its own sigma schedule (num_inference_steps),
+ # while other schedulers can use custom sigmas if supported
+ is_lcm = self.scheduler == "lcm"
+ set_timesteps_sig = inspect.signature(scheduler.set_timesteps)
+ if not is_lcm and "sigmas" in set_timesteps_sig.parameters:
+ scheduler.set_timesteps(sigmas=sigmas, device=device)
+ else:
+ # LCM or a scheduler without custom-sigma support computes its own
+ # schedule from num_inference_steps. That can diverge from sigmas[0]
+ # used in the img2img preblend above.
+ scheduler.set_timesteps(num_inference_steps=total_steps, device=device)
+
+ # For Heun scheduler, the number of actual steps may differ
+ num_scheduler_steps = len(scheduler.timesteps)
+ else:
+ num_scheduler_steps = total_steps
+
+ with ExitStack() as exit_stack:
+ # Get transformer config to determine if it's quantized
+ transformer_config = context.models.get_config(self.transformer.transformer)
+
+ # Determine if the model is quantized.
+ # If the model is quantized, then we need to apply the LoRA weights as sidecar layers. This results in
+ # slower inference than direct patching, but is agnostic to the quantization format.
+ if transformer_config.format in [ModelFormat.Diffusers, ModelFormat.Checkpoint]:
+ model_is_quantized = False
+ elif transformer_config.format in [ModelFormat.GGUFQuantized]:
+ model_is_quantized = True
+ else:
+ raise ValueError(f"Unsupported Z-Image model format: {transformer_config.format}")
+
+ # Load transformer - always use base transformer, control is handled via extension
+ (cached_weights, transformer) = exit_stack.enter_context(transformer_info.model_on_device())
+
+ # Prepare control extension if control is provided
+ control_extension: ZImageControlNetExtension | None = None
+
+ if self.control is not None:
+ # Load control adapter using context manager (proper GPU memory management)
+ control_model_info = context.models.load(self.control.control_model)
+ (_, control_adapter) = exit_stack.enter_context(control_model_info.model_on_device())
+ assert isinstance(control_adapter, ZImageControlAdapter)
+
+ # Get control_in_dim from adapter config (16 for V1, 33 for V2.0)
+ adapter_config = control_adapter.config
+ control_in_dim = adapter_config.get("control_in_dim", 16)
+ num_control_blocks = adapter_config.get("num_control_blocks", 6)
+
+ # Log control configuration for debugging
+ version = "V2.0" if control_in_dim > 16 else "V1"
+ context.util.signal_progress(
+ f"Using Z-Image ControlNet {version} (Extension): control_in_dim={control_in_dim}, "
+ f"num_blocks={num_control_blocks}, scale={self.control.control_context_scale}"
+ )
+
+ # Load and prepare control image - must be VAE-encoded!
+ if self.vae is None:
+ raise ValueError("VAE is required when using Z-Image Control. Connect a VAE to the 'vae' input.")
+
+ control_image = context.images.get_pil(self.control.image_name)
+
+ # Resize control image to match output dimensions
+ control_image = control_image.convert("RGB")
+ control_image = control_image.resize((self.width, self.height), Image.Resampling.LANCZOS)
+
+ # Convert to tensor format for VAE encoding
+ from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+
+ control_image_tensor = image_resized_to_grid_as_tensor(control_image)
+ if control_image_tensor.dim() == 3:
+ control_image_tensor = einops.rearrange(control_image_tensor, "c h w -> 1 c h w")
+
+ # Encode control image through VAE to get latents
+ vae_info = context.models.load(self.vae.vae)
+ control_latents = ZImageImageToLatentsInvocation.vae_encode(
+ vae_info=vae_info,
+ image_tensor=control_image_tensor,
+ )
+
+ # Move to inference device/dtype
+ control_latents = control_latents.to(device=device, dtype=inference_dtype)
+
+ # Add frame dimension: [B, C, H, W] -> [C, 1, H, W] (single image)
+ control_latents = control_latents.squeeze(0).unsqueeze(1)
+
+ # Prepare control_cond based on control_in_dim
+ # V1: 16 channels (just control latents)
+ # V2.0: 33 channels = 16 control + 16 reference + 1 mask
+ # - Channels 0-15: control image latents (from VAE encoding)
+ # - Channels 16-31: reference/inpaint image latents (zeros for pure control)
+ # - Channel 32: inpaint mask (1.0 = don't inpaint, 0.0 = inpaint region)
+ # For pure control (no inpainting), we set mask=1 to tell model "use control, don't inpaint"
+ c, f, h, w = control_latents.shape
+ if c < control_in_dim:
+ padding_channels = control_in_dim - c
+ if padding_channels == 17:
+ # V2.0: 16 reference channels (zeros) + 1 mask channel (ones)
+ ref_padding = torch.zeros(
+ (16, f, h, w),
+ device=device,
+ dtype=inference_dtype,
+ )
+ # Mask channel = 1.0 means "don't inpaint this region, use control signal"
+ mask_channel = torch.ones(
+ (1, f, h, w),
+ device=device,
+ dtype=inference_dtype,
+ )
+ control_latents = torch.cat([control_latents, ref_padding, mask_channel], dim=0)
+ else:
+ # Generic padding with zeros for other cases
+ zero_padding = torch.zeros(
+ (padding_channels, f, h, w),
+ device=device,
+ dtype=inference_dtype,
+ )
+ control_latents = torch.cat([control_latents, zero_padding], dim=0)
+
+ # Create control extension (adapter is already on device from model_on_device)
+ control_extension = ZImageControlNetExtension(
+ control_adapter=control_adapter,
+ control_cond=control_latents,
+ weight=self.control.control_context_scale,
+ begin_step_percent=self.control.begin_step_percent,
+ end_step_percent=self.control.end_step_percent,
+ )
+
+ # Apply LoRA models to the transformer.
+ # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching.
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=transformer,
+ patches=self._lora_iterator(context),
+ prefix=Z_IMAGE_LORA_TRANSFORMER_PREFIX,
+ dtype=inference_dtype,
+ cached_weights=cached_weights,
+ force_sidecar_patching=model_is_quantized,
+ )
+ )
+
+ # Apply regional prompting patch if we have regional masks
+ exit_stack.enter_context(
+ patch_transformer_for_regional_prompting(
+ transformer=transformer,
+ regional_attn_mask=regional_extension.regional_attn_mask,
+ img_seq_len=img_seq_len,
+ positive_cap_feats=pos_prompt_embeds,
+ )
+ )
+
+ # Denoising loop - supports both built-in Euler and diffusers schedulers
+ # Track user-facing step for progress (accounts for Heun's double steps)
+ user_step = 0
+
+ if use_scheduler and scheduler is not None:
+ # Use diffusers scheduler for stepping
+ # Use tqdm with total_steps (user-facing steps) not num_scheduler_steps (internal steps)
+ # This ensures progress bar shows 1/8, 2/8, etc. even when scheduler uses more internal steps
+ pbar = tqdm(total=total_steps, desc=f"Denoising{TorchDevice.get_session_device_label()}")
+ for step_index in range(num_scheduler_steps):
+ sched_timestep = scheduler.timesteps[step_index]
+ # Convert scheduler timestep (0-1000) to normalized sigma (0-1)
+ sigma_curr = sched_timestep.item() / scheduler.config.num_train_timesteps
+
+ # For Heun scheduler, track if we're in first or second order step
+ is_heun = hasattr(scheduler, "state_in_first_order")
+ in_first_order = scheduler.state_in_first_order if is_heun else True
+
+ # Timestep tensor for Z-Image model
+ # The model expects t=0 at start (noise) and t=1 at end (clean)
+ model_t = 1.0 - sigma_curr
+ timestep = torch.tensor([model_t], device=device, dtype=inference_dtype).expand(latents.shape[0])
+
+ # Run transformer for positive prediction
+ latent_model_input = latents.to(transformer.dtype)
+ latent_model_input = latent_model_input.unsqueeze(2) # Add frame dimension
+ latent_model_input_list = list(latent_model_input.unbind(dim=0))
+
+ # Determine if control should be applied at this step
+ apply_control = control_extension is not None and control_extension.should_apply(
+ user_step, total_steps
+ )
+
+ # Run forward pass
+ if apply_control:
+ model_out_list, _ = z_image_forward_with_control(
+ transformer=transformer,
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[pos_prompt_embeds],
+ control_extension=control_extension,
+ )
+ else:
+ model_output = transformer(
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[pos_prompt_embeds],
+ )
+ model_out_list = model_output[0]
+
+ noise_pred_cond = torch.stack([t.float() for t in model_out_list], dim=0)
+ noise_pred_cond = noise_pred_cond.squeeze(2)
+ noise_pred_cond = -noise_pred_cond # Z-Image uses v-prediction with negation
+
+ # Apply CFG if enabled
+ if do_classifier_free_guidance and neg_prompt_embeds is not None:
+ if apply_control:
+ model_out_list_uncond, _ = z_image_forward_with_control(
+ transformer=transformer,
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[neg_prompt_embeds],
+ control_extension=control_extension,
+ )
+ else:
+ model_output_uncond = transformer(
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[neg_prompt_embeds],
+ )
+ model_out_list_uncond = model_output_uncond[0]
+
+ noise_pred_uncond = torch.stack([t.float() for t in model_out_list_uncond], dim=0)
+ noise_pred_uncond = noise_pred_uncond.squeeze(2)
+ noise_pred_uncond = -noise_pred_uncond
+ noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ # Use scheduler.step() for the update
+ step_output = scheduler.step(model_output=noise_pred, timestep=sched_timestep, sample=latents)
+ latents = step_output.prev_sample
+
+ # Get sigma_prev for inpainting (next sigma value)
+ if step_index + 1 < len(scheduler.sigmas):
+ sigma_prev = scheduler.sigmas[step_index + 1].item()
+ else:
+ sigma_prev = 0.0
+
+ if inpaint_extension is not None:
+ latents = inpaint_extension.merge_intermediate_latents_with_init_latents(latents, sigma_prev)
+
+ # For Heun, only increment user step after second-order step completes
+ if is_heun:
+ if not in_first_order:
+ user_step += 1
+ # Only call step_callback if we haven't exceeded total_steps
+ if user_step <= total_steps:
+ pbar.update(1)
+ step_callback(
+ PipelineIntermediateState(
+ step=user_step,
+ order=2,
+ total_steps=total_steps,
+ timestep=int(sigma_curr * 1000),
+ latents=latents,
+ ),
+ )
+ else:
+ # For first-order schedulers (Euler, LCM)
+ user_step += 1
+ if user_step <= total_steps:
+ pbar.update(1)
+ step_callback(
+ PipelineIntermediateState(
+ step=user_step,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(sigma_curr * 1000),
+ latents=latents,
+ ),
+ )
+ pbar.close()
+ else:
+ # Original Euler implementation (default, optimized for Z-Image)
+ for step_idx in tqdm(range(total_steps), desc=f"Denoising{TorchDevice.get_session_device_label()}"):
+ sigma_curr = sigmas[step_idx]
+ sigma_prev = sigmas[step_idx + 1]
+
+ # Timestep tensor for Z-Image model
+ # The model expects t=0 at start (noise) and t=1 at end (clean)
+ # Sigma goes from 1 (noise) to 0 (clean), so model_t = 1 - sigma
+ model_t = 1.0 - sigma_curr
+ timestep = torch.tensor([model_t], device=device, dtype=inference_dtype).expand(latents.shape[0])
+
+ # Run transformer for positive prediction
+ # Z-Image transformer expects: x as list of [C, 1, H, W] tensors, t, cap_feats as list
+ # Prepare latent input: [B, C, H, W] -> [B, C, 1, H, W] -> list of [C, 1, H, W]
+ latent_model_input = latents.to(transformer.dtype)
+ latent_model_input = latent_model_input.unsqueeze(2) # Add frame dimension
+ latent_model_input_list = list(latent_model_input.unbind(dim=0))
+
+ # Determine if control should be applied at this step
+ apply_control = control_extension is not None and control_extension.should_apply(
+ step_idx, total_steps
+ )
+
+ # Run forward pass - use custom forward with control if extension is active
+ if apply_control:
+ model_out_list, _ = z_image_forward_with_control(
+ transformer=transformer,
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[pos_prompt_embeds],
+ control_extension=control_extension,
+ )
+ else:
+ model_output = transformer(
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[pos_prompt_embeds],
+ )
+ model_out_list = model_output[0] # Extract list of tensors from tuple
+
+ noise_pred_cond = torch.stack([t.float() for t in model_out_list], dim=0)
+ noise_pred_cond = noise_pred_cond.squeeze(2) # Remove frame dimension
+ noise_pred_cond = -noise_pred_cond # Z-Image uses v-prediction with negation
+
+ # Apply CFG if enabled
+ if do_classifier_free_guidance and neg_prompt_embeds is not None:
+ if apply_control:
+ model_out_list_uncond, _ = z_image_forward_with_control(
+ transformer=transformer,
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[neg_prompt_embeds],
+ control_extension=control_extension,
+ )
+ else:
+ model_output_uncond = transformer(
+ x=latent_model_input_list,
+ t=timestep,
+ cap_feats=[neg_prompt_embeds],
+ )
+ model_out_list_uncond = model_output_uncond[0] # Extract list of tensors from tuple
+
+ noise_pred_uncond = torch.stack([t.float() for t in model_out_list_uncond], dim=0)
+ noise_pred_uncond = noise_pred_uncond.squeeze(2)
+ noise_pred_uncond = -noise_pred_uncond
+ noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_cond - noise_pred_uncond)
+ else:
+ noise_pred = noise_pred_cond
+
+ # Euler step
+ latents_dtype = latents.dtype
+ latents = latents.to(dtype=torch.float32)
+ latents = latents + (sigma_prev - sigma_curr) * noise_pred
+ latents = latents.to(dtype=latents_dtype)
+
+ if inpaint_extension is not None:
+ latents = inpaint_extension.merge_intermediate_latents_with_init_latents(latents, sigma_prev)
+
+ step_callback(
+ PipelineIntermediateState(
+ step=step_idx + 1,
+ order=1,
+ total_steps=total_steps,
+ timestep=int(sigma_curr * 1000),
+ latents=latents,
+ ),
+ )
+
+ return latents
+
+ def _prepare_noise_tensor(
+ self, context: InvocationContext, inference_dtype: torch.dtype, device: torch.device
+ ) -> torch.Tensor:
+ if self.noise is not None:
+ noise = context.tensors.load(self.noise.latents_name).to(device=device, dtype=inference_dtype)
+ validate_noise_tensor_shape(noise, "Z-Image", self.width, self.height)
+ return noise
+
+ return self._get_noise(
+ batch_size=1,
+ num_channels_latents=16,
+ height=self.height,
+ width=self.width,
+ dtype=inference_dtype,
+ device=device,
+ seed=self.seed,
+ )
+
+ def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
+ def step_callback(state: PipelineIntermediateState) -> None:
+ context.util.sd_step_callback(state, BaseModelType.ZImage)
+
+ return step_callback
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the transformer."""
+ for lora in self.transformer.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}. "
+ "The LoRA model may be corrupted or incompatible."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/invocations/z_image_image_to_latents.py b/invokeai/app/invocations/z_image_image_to_latents.py
new file mode 100644
index 00000000000..263346e2962
--- /dev/null
+++ b/invokeai/app/invocations/z_image_image_to_latents.py
@@ -0,0 +1,110 @@
+from typing import Union
+
+import einops
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ ImageField,
+ Input,
+ InputField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import LatentsOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
+from invokeai.backend.model_manager.load.load_base import LoadedModel
+from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+# Z-Image can use either the Diffusers AutoencoderKL or the FLUX AutoEncoder
+ZImageVAE = Union[AutoencoderKL, FluxAutoEncoder]
+
+
+@invocation(
+ "z_image_i2l",
+ title="Image to Latents - Z-Image",
+ tags=["image", "latents", "vae", "i2l", "z-image"],
+ category="latents",
+ version="1.1.0",
+ classification=Classification.Prototype,
+)
+class ZImageImageToLatentsInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates latents from an image using Z-Image VAE (supports both Diffusers and FLUX VAE)."""
+
+ image: ImageField = InputField(description="The image to encode.")
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @staticmethod
+ def vae_encode(vae_info: LoadedModel, image_tensor: torch.Tensor) -> torch.Tensor:
+ if not isinstance(vae_info.model, (AutoencoderKL, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKL or FluxAutoEncoder for Z-Image VAE, got {type(vae_info.model).__name__}. "
+ "Ensure you are using a compatible VAE model."
+ )
+
+ # Estimate working memory needed for VAE encode
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="encode",
+ image_tensor=image_tensor,
+ vae=vae_info.model,
+ )
+
+ with vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ if not isinstance(vae, (AutoencoderKL, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKL or FluxAutoEncoder, got {type(vae).__name__}. "
+ "VAE model type changed unexpectedly after loading."
+ )
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ image_tensor = image_tensor.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ with torch.inference_mode():
+ if isinstance(vae, FluxAutoEncoder):
+ # FLUX VAE handles scaling internally
+ generator = torch.Generator(device=TorchDevice.choose_torch_device()).manual_seed(0)
+ latents = vae.encode(image_tensor, sample=True, generator=generator)
+ else:
+ # AutoencoderKL - needs manual scaling
+ vae.disable_tiling()
+ image_tensor_dist = vae.encode(image_tensor).latent_dist
+ latents: torch.Tensor = image_tensor_dist.sample().to(dtype=vae.dtype)
+
+ # Apply scaling_factor and shift_factor from VAE config
+ # Z-Image uses: latents = (latents - shift_factor) * scaling_factor
+ scaling_factor = vae.config.scaling_factor
+ shift_factor = getattr(vae.config, "shift_factor", None)
+
+ if shift_factor is not None:
+ latents = latents - shift_factor
+ latents = latents * scaling_factor
+
+ return latents
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> LatentsOutput:
+ image = context.images.get_pil(self.image.image_name)
+
+ image_tensor = image_resized_to_grid_as_tensor(image.convert("RGB"))
+ if image_tensor.dim() == 3:
+ image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, (AutoencoderKL, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKL or FluxAutoEncoder for Z-Image VAE, got {type(vae_info.model).__name__}. "
+ "Ensure you are using a compatible VAE model."
+ )
+
+ context.util.signal_progress("Running VAE")
+ latents = self.vae_encode(vae_info=vae_info, image_tensor=image_tensor)
+
+ latents = latents.to("cpu")
+ name = context.tensors.save(tensor=latents)
+ return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
diff --git a/invokeai/app/invocations/z_image_latents_to_image.py b/invokeai/app/invocations/z_image_latents_to_image.py
new file mode 100644
index 00000000000..a2e6fdcc077
--- /dev/null
+++ b/invokeai/app/invocations/z_image_latents_to_image.py
@@ -0,0 +1,111 @@
+from contextlib import nullcontext
+from typing import Union
+
+import torch
+from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
+from einops import rearrange
+from PIL import Image
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ LatentsField,
+ WithBoard,
+ WithMetadata,
+)
+from invokeai.app.invocations.model import VAEField
+from invokeai.app.invocations.primitives import ImageOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
+from invokeai.backend.stable_diffusion.extensions.seamless import SeamlessExt
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_flux
+
+# Z-Image can use either the Diffusers AutoencoderKL or the FLUX AutoEncoder
+ZImageVAE = Union[AutoencoderKL, FluxAutoEncoder]
+
+
+@invocation(
+ "z_image_l2i",
+ title="Latents to Image - Z-Image",
+ tags=["latents", "image", "vae", "l2i", "z-image"],
+ category="latents",
+ version="1.1.0",
+ classification=Classification.Prototype,
+)
+class ZImageLatentsToImageInvocation(BaseInvocation, WithMetadata, WithBoard):
+ """Generates an image from latents using Z-Image VAE (supports both Diffusers and FLUX VAE)."""
+
+ latents: LatentsField = InputField(description=FieldDescriptions.latents, input=Input.Connection)
+ vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection)
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ImageOutput:
+ latents = context.tensors.load(self.latents.latents_name)
+
+ vae_info = context.models.load(self.vae.vae)
+ if not isinstance(vae_info.model, (AutoencoderKL, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKL or FluxAutoEncoder for Z-Image VAE, got {type(vae_info.model).__name__}. "
+ "Ensure you are using a compatible VAE model."
+ )
+
+ is_flux_vae = isinstance(vae_info.model, FluxAutoEncoder)
+
+ # Estimate working memory needed for VAE decode
+ estimated_working_memory = estimate_vae_working_memory_flux(
+ operation="decode",
+ image_tensor=latents,
+ vae=vae_info.model,
+ )
+
+ # FLUX VAE doesn't support seamless, so only apply for AutoencoderKL
+ seamless_context = (
+ nullcontext() if is_flux_vae else SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes)
+ )
+
+ with seamless_context, vae_info.model_on_device(working_mem_bytes=estimated_working_memory) as (_, vae):
+ context.util.signal_progress("Running VAE")
+ if not isinstance(vae, (AutoencoderKL, FluxAutoEncoder)):
+ raise TypeError(
+ f"Expected AutoencoderKL or FluxAutoEncoder, got {type(vae).__name__}. "
+ "VAE model type changed unexpectedly after loading."
+ )
+
+ vae_dtype = next(iter(vae.parameters())).dtype
+ latents = latents.to(device=TorchDevice.choose_torch_device(), dtype=vae_dtype)
+
+ # Disable tiling for AutoencoderKL
+ if isinstance(vae, AutoencoderKL):
+ vae.disable_tiling()
+
+ # Clear memory as VAE decode can request a lot
+ TorchDevice.empty_cache()
+
+ with torch.inference_mode():
+ if isinstance(vae, FluxAutoEncoder):
+ # FLUX VAE handles scaling internally
+ img = vae.decode(latents)
+ else:
+ # AutoencoderKL - Apply scaling_factor and shift_factor from VAE config
+ # Z-Image uses: latents = latents / scaling_factor + shift_factor
+ scaling_factor = vae.config.scaling_factor
+ shift_factor = getattr(vae.config, "shift_factor", None)
+
+ latents = latents / scaling_factor
+ if shift_factor is not None:
+ latents = latents + shift_factor
+
+ img = vae.decode(latents, return_dict=False)[0]
+
+ img = img.clamp(-1, 1)
+ img = rearrange(img[0], "c h w -> h w c")
+ img_pil = Image.fromarray((127.5 * (img + 1.0)).byte().cpu().numpy())
+
+ TorchDevice.empty_cache()
+
+ image_dto = context.images.save(image=img_pil)
+
+ return ImageOutput.build(image_dto)
diff --git a/invokeai/app/invocations/z_image_lora_loader.py b/invokeai/app/invocations/z_image_lora_loader.py
new file mode 100644
index 00000000000..54c353a6ab7
--- /dev/null
+++ b/invokeai/app/invocations/z_image_lora_loader.py
@@ -0,0 +1,177 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, Qwen3EncoderField, TransformerField
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
+
+
+@invocation_output("z_image_lora_loader_output")
+class ZImageLoRALoaderOutput(BaseInvocationOutput):
+ """Z-Image LoRA Loader Output"""
+
+ transformer: Optional[TransformerField] = OutputField(
+ default=None, description=FieldDescriptions.transformer, title="Z-Image Transformer"
+ )
+ qwen3_encoder: Optional[Qwen3EncoderField] = OutputField(
+ default=None, description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder"
+ )
+
+
+@invocation(
+ "z_image_lora_loader",
+ title="Apply LoRA - Z-Image",
+ tags=["lora", "model", "z-image"],
+ category="model",
+ version="1.0.0",
+)
+class ZImageLoRALoaderInvocation(BaseInvocation):
+ """Apply a LoRA model to a Z-Image transformer and/or Qwen3 text encoder."""
+
+ lora: ModelIdentifierField = InputField(
+ description=FieldDescriptions.lora_model,
+ title="LoRA",
+ ui_model_base=BaseModelType.ZImage,
+ ui_model_type=ModelType.LoRA,
+ )
+ weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight)
+ transformer: TransformerField | None = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Z-Image Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> ZImageLoRALoaderOutput:
+ lora_key = self.lora.key
+
+ if not context.models.exists(lora_key):
+ raise ValueError(f"Unknown lora: {lora_key}!")
+
+ # Check for existing LoRAs with the same key.
+ if self.transformer and any(lora.lora.key == lora_key for lora in self.transformer.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to transformer.')
+ if self.qwen3_encoder and any(lora.lora.key == lora_key for lora in self.qwen3_encoder.loras):
+ raise ValueError(f'LoRA "{lora_key}" already applied to Qwen3 encoder.')
+
+ # Warn on variant mismatch between LoRA and transformer.
+ lora_config = context.models.get_config(lora_key)
+ lora_variant = getattr(lora_config, "variant", None)
+ if lora_variant and self.transformer is not None:
+ transformer_config = context.models.get_config(self.transformer.transformer.key)
+ transformer_variant = getattr(transformer_config, "variant", None)
+ if transformer_variant and lora_variant != transformer_variant:
+ context.logger.warning(
+ f"LoRA variant mismatch: LoRA '{lora_config.name}' is for {lora_variant.value} "
+ f"but transformer is {transformer_variant.value}. This may cause unexpected results."
+ )
+
+ output = ZImageLoRALoaderOutput()
+
+ # Attach LoRA layers to the models.
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+ output.transformer.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+ output.qwen3_encoder.loras.append(
+ LoRAField(
+ lora=self.lora,
+ weight=self.weight,
+ )
+ )
+
+ return output
+
+
+@invocation(
+ "z_image_lora_collection_loader",
+ title="Apply LoRA Collection - Z-Image",
+ tags=["lora", "model", "z-image"],
+ category="model",
+ version="1.0.0",
+)
+class ZImageLoRACollectionLoader(BaseInvocation):
+ """Applies a collection of LoRAs to a Z-Image transformer."""
+
+ loras: Optional[LoRAField | list[LoRAField]] = InputField(
+ default=None, description="LoRA models and weights. May be a single LoRA or collection.", title="LoRAs"
+ )
+
+ transformer: Optional[TransformerField] = InputField(
+ default=None,
+ description=FieldDescriptions.transformer,
+ input=Input.Connection,
+ title="Transformer",
+ )
+ qwen3_encoder: Qwen3EncoderField | None = InputField(
+ default=None,
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+
+ def invoke(self, context: InvocationContext) -> ZImageLoRALoaderOutput:
+ output = ZImageLoRALoaderOutput()
+ loras = self.loras if isinstance(self.loras, list) else [self.loras]
+ added_loras: list[str] = []
+
+ if self.transformer is not None:
+ output.transformer = self.transformer.model_copy(deep=True)
+
+ if self.qwen3_encoder is not None:
+ output.qwen3_encoder = self.qwen3_encoder.model_copy(deep=True)
+
+ for lora in loras:
+ if lora is None:
+ continue
+ if lora.lora.key in added_loras:
+ continue
+
+ if not context.models.exists(lora.lora.key):
+ raise Exception(f"Unknown lora: {lora.lora.key}!")
+
+ if lora.lora.base is not BaseModelType.ZImage:
+ raise ValueError(
+ f"LoRA '{lora.lora.key}' is for {lora.lora.base.value if lora.lora.base else 'unknown'} models, "
+ "not Z-Image models. Ensure you are using a Z-Image compatible LoRA."
+ )
+
+ # Warn on variant mismatch between LoRA and transformer.
+ lora_config = context.models.get_config(lora.lora.key)
+ lora_variant = getattr(lora_config, "variant", None)
+ if lora_variant and self.transformer is not None:
+ transformer_config = context.models.get_config(self.transformer.transformer.key)
+ transformer_variant = getattr(transformer_config, "variant", None)
+ if transformer_variant and lora_variant != transformer_variant:
+ context.logger.warning(
+ f"LoRA variant mismatch: LoRA '{lora_config.name}' is for {lora_variant.value} "
+ f"but transformer is {transformer_variant.value}. This may cause unexpected results."
+ )
+
+ added_loras.append(lora.lora.key)
+
+ if self.transformer is not None and output.transformer is not None:
+ output.transformer.loras.append(lora)
+
+ if self.qwen3_encoder is not None and output.qwen3_encoder is not None:
+ output.qwen3_encoder.loras.append(lora)
+
+ return output
diff --git a/invokeai/app/invocations/z_image_model_loader.py b/invokeai/app/invocations/z_image_model_loader.py
new file mode 100644
index 00000000000..4d746061dcc
--- /dev/null
+++ b/invokeai/app/invocations/z_image_model_loader.py
@@ -0,0 +1,135 @@
+from typing import Optional
+
+from invokeai.app.invocations.baseinvocation import (
+ BaseInvocation,
+ BaseInvocationOutput,
+ Classification,
+ invocation,
+ invocation_output,
+)
+from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField
+from invokeai.app.invocations.model import (
+ ModelIdentifierField,
+ Qwen3EncoderField,
+ TransformerField,
+ VAEField,
+)
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType
+
+
+@invocation_output("z_image_model_loader_output")
+class ZImageModelLoaderOutput(BaseInvocationOutput):
+ """Z-Image base model loader output."""
+
+ transformer: TransformerField = OutputField(description=FieldDescriptions.transformer, title="Transformer")
+ qwen3_encoder: Qwen3EncoderField = OutputField(description=FieldDescriptions.qwen3_encoder, title="Qwen3 Encoder")
+ vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE")
+
+
+@invocation(
+ "z_image_model_loader",
+ title="Main Model - Z-Image",
+ tags=["model", "z-image"],
+ category="model",
+ version="3.0.0",
+ classification=Classification.Prototype,
+)
+class ZImageModelLoaderInvocation(BaseInvocation):
+ """Loads a Z-Image model, outputting its submodels.
+
+ Similar to FLUX, you can mix and match components:
+ - Transformer: From Z-Image main model (GGUF quantized or Diffusers format)
+ - VAE: Separate FLUX VAE (shared with FLUX models) or from a Diffusers Z-Image model
+ - Qwen3 Encoder: Separate Qwen3Encoder model or from a Diffusers Z-Image model
+ """
+
+ model: ModelIdentifierField = InputField(
+ description=FieldDescriptions.z_image_model,
+ input=Input.Direct,
+ ui_model_base=BaseModelType.ZImage,
+ ui_model_type=ModelType.Main,
+ title="Transformer",
+ )
+
+ vae_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone VAE model. Z-Image uses the same VAE as FLUX (16-channel). "
+ "If not provided, VAE will be loaded from the Qwen3 Source model.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.Flux,
+ ui_model_type=ModelType.VAE,
+ title="VAE",
+ )
+
+ qwen3_encoder_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Standalone Qwen3 Encoder model. "
+ "If not provided, encoder will be loaded from the Qwen3 Source model.",
+ input=Input.Direct,
+ ui_model_type=ModelType.Qwen3Encoder,
+ title="Qwen3 Encoder",
+ )
+
+ qwen3_source_model: Optional[ModelIdentifierField] = InputField(
+ default=None,
+ description="Diffusers Z-Image model to extract VAE and/or Qwen3 encoder from. "
+ "Use this if you don't have separate VAE/Qwen3 models. "
+ "Ignored if both VAE and Qwen3 Encoder are provided separately.",
+ input=Input.Direct,
+ ui_model_base=BaseModelType.ZImage,
+ ui_model_type=ModelType.Main,
+ ui_model_format=ModelFormat.Diffusers,
+ title="Qwen3 Source (Diffusers)",
+ )
+
+ def invoke(self, context: InvocationContext) -> ZImageModelLoaderOutput:
+ # Transformer always comes from the main model
+ transformer = self.model.model_copy(update={"submodel_type": SubModelType.Transformer})
+
+ # Determine VAE source
+ if self.vae_model is not None:
+ # Use standalone FLUX VAE
+ vae = self.vae_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ elif self.qwen3_source_model is not None:
+ # Extract from Diffusers Z-Image model
+ self._validate_diffusers_format(context, self.qwen3_source_model, "Qwen3 Source")
+ vae = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.VAE})
+ else:
+ raise ValueError(
+ "No VAE source provided. Either set 'VAE' to a FLUX VAE model, "
+ "or set 'Qwen3 Source' to a Diffusers Z-Image model."
+ )
+
+ # Determine Qwen3 Encoder source
+ if self.qwen3_encoder_model is not None:
+ # Use standalone Qwen3 Encoder
+ qwen3_tokenizer = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.qwen3_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ elif self.qwen3_source_model is not None:
+ # Extract from Diffusers Z-Image model
+ self._validate_diffusers_format(context, self.qwen3_source_model, "Qwen3 Source")
+ qwen3_tokenizer = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.Tokenizer})
+ qwen3_encoder = self.qwen3_source_model.model_copy(update={"submodel_type": SubModelType.TextEncoder})
+ else:
+ raise ValueError(
+ "No Qwen3 Encoder source provided. Either set 'Qwen3 Encoder' to a standalone model, "
+ "or set 'Qwen3 Source' to a Diffusers Z-Image model."
+ )
+
+ return ZImageModelLoaderOutput(
+ transformer=TransformerField(transformer=transformer, loras=[]),
+ qwen3_encoder=Qwen3EncoderField(tokenizer=qwen3_tokenizer, text_encoder=qwen3_encoder),
+ vae=VAEField(vae=vae),
+ )
+
+ def _validate_diffusers_format(
+ self, context: InvocationContext, model: ModelIdentifierField, model_name: str
+ ) -> None:
+ """Validate that a model is in Diffusers format."""
+ config = context.models.get_config(model)
+ if config.format != ModelFormat.Diffusers:
+ raise ValueError(
+ f"The {model_name} model must be a Diffusers format Z-Image model. "
+ f"The selected model '{config.name}' is in {config.format.value} format."
+ )
diff --git a/invokeai/app/invocations/z_image_seed_variance_enhancer.py b/invokeai/app/invocations/z_image_seed_variance_enhancer.py
new file mode 100644
index 00000000000..72819a966a2
--- /dev/null
+++ b/invokeai/app/invocations/z_image_seed_variance_enhancer.py
@@ -0,0 +1,110 @@
+import torch
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ ZImageConditioningField,
+)
+from invokeai.app.invocations.primitives import ZImageConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ ConditioningFieldData,
+ ZImageConditioningInfo,
+)
+
+
+@invocation(
+ "z_image_seed_variance_enhancer",
+ title="Seed Variance Enhancer - Z-Image",
+ tags=["conditioning", "z-image", "variance", "seed"],
+ category="prompt",
+ version="1.0.0",
+ classification=Classification.Prototype,
+)
+class ZImageSeedVarianceEnhancerInvocation(BaseInvocation):
+ """Adds seed-based noise to Z-Image conditioning to increase variance between seeds.
+
+ Z-Image-Turbo can produce relatively similar images with different seeds,
+ making it harder to explore variations of a prompt. This node implements
+ reproducible, seed-based noise injection into text embeddings to increase
+ visual variation while maintaining reproducibility.
+
+ The noise strength is auto-calibrated relative to the embedding's standard
+ deviation, ensuring consistent results across different prompts.
+ """
+
+ conditioning: ZImageConditioningField = InputField(
+ description=FieldDescriptions.cond,
+ input=Input.Connection,
+ title="Conditioning",
+ )
+ seed: int = InputField(
+ default=0,
+ ge=0,
+ description="Seed for reproducible noise generation. Different seeds produce different noise patterns.",
+ )
+ strength: float = InputField(
+ default=0.1,
+ ge=0.0,
+ le=2.0,
+ description="Noise strength as multiplier of embedding std. 0=off, 0.1=subtle, 0.5=strong.",
+ )
+ randomize_percent: float = InputField(
+ default=50.0,
+ ge=1.0,
+ le=100.0,
+ description="Percentage of embedding values to add noise to (1-100). Lower values create more selective noise patterns.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ZImageConditioningOutput:
+ # Load conditioning data
+ cond_data = context.conditioning.load(self.conditioning.conditioning_name)
+ assert len(cond_data.conditionings) == 1, "Expected exactly one conditioning tensor"
+ z_image_conditioning = cond_data.conditionings[0]
+ assert isinstance(z_image_conditioning, ZImageConditioningInfo), "Expected ZImageConditioningInfo"
+
+ # Early return if strength is zero (no modification needed)
+ if self.strength == 0:
+ return ZImageConditioningOutput(conditioning=self.conditioning)
+
+ # Clone embeddings to avoid modifying the original
+ prompt_embeds = z_image_conditioning.prompt_embeds.clone()
+
+ # Calculate actual noise strength based on embedding statistics
+ # This auto-calibration ensures consistent results across different prompts
+ embed_std = torch.std(prompt_embeds).item()
+ actual_strength = self.strength * embed_std
+
+ # Generate deterministic noise using the seed
+ generator = torch.Generator(device=prompt_embeds.device)
+ generator.manual_seed(self.seed)
+ noise = torch.rand(
+ prompt_embeds.shape, generator=generator, device=prompt_embeds.device, dtype=prompt_embeds.dtype
+ )
+ noise = noise * 2 - 1 # Scale to [-1, 1)
+ noise = noise * actual_strength
+
+ # Create selective mask for noise application
+ generator.manual_seed(self.seed + 1)
+ noise_mask = torch.bernoulli(
+ torch.ones_like(prompt_embeds) * (self.randomize_percent / 100.0),
+ generator=generator,
+ ).bool()
+
+ # Apply noise only to masked positions
+ prompt_embeds = prompt_embeds + (noise * noise_mask)
+
+ # Save modified conditioning
+ new_conditioning = ZImageConditioningInfo(prompt_embeds=prompt_embeds)
+ conditioning_data = ConditioningFieldData(conditionings=[new_conditioning])
+ conditioning_name = context.conditioning.save(conditioning_data)
+
+ return ZImageConditioningOutput(
+ conditioning=ZImageConditioningField(
+ conditioning_name=conditioning_name,
+ mask=self.conditioning.mask,
+ )
+ )
diff --git a/invokeai/app/invocations/z_image_text_encoder.py b/invokeai/app/invocations/z_image_text_encoder.py
new file mode 100644
index 00000000000..148cff5c269
--- /dev/null
+++ b/invokeai/app/invocations/z_image_text_encoder.py
@@ -0,0 +1,210 @@
+from contextlib import ExitStack
+from typing import Iterator, Optional, Tuple
+
+import torch
+from transformers import PreTrainedModel, PreTrainedTokenizerBase
+
+from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
+from invokeai.app.invocations.fields import (
+ FieldDescriptions,
+ Input,
+ InputField,
+ TensorField,
+ UIComponent,
+ ZImageConditioningField,
+)
+from invokeai.app.invocations.model import Qwen3EncoderField
+from invokeai.app.invocations.primitives import ZImageConditioningOutput
+from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
+from invokeai.backend.patches.layer_patcher import LayerPatcher
+from invokeai.backend.patches.lora_conversions.z_image_lora_constants import Z_IMAGE_LORA_QWEN3_PREFIX
+from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
+ ConditioningFieldData,
+ ZImageConditioningInfo,
+)
+from invokeai.backend.util.devices import TorchDevice
+
+# Z-Image max sequence length based on diffusers default
+Z_IMAGE_MAX_SEQ_LEN = 512
+
+
+@invocation(
+ "z_image_text_encoder",
+ title="Prompt - Z-Image",
+ tags=["prompt", "conditioning", "z-image"],
+ category="prompt",
+ version="1.1.0",
+ classification=Classification.Prototype,
+ idle_gpu_offloadable=True,
+)
+class ZImageTextEncoderInvocation(BaseInvocation):
+ """Encodes and preps a prompt for a Z-Image image.
+
+ Supports regional prompting by connecting a mask input.
+ """
+
+ prompt: str = InputField(description="Text prompt to encode.", ui_component=UIComponent.Textarea)
+ qwen3_encoder: Qwen3EncoderField = InputField(
+ title="Qwen3 Encoder",
+ description=FieldDescriptions.qwen3_encoder,
+ input=Input.Connection,
+ )
+ mask: Optional[TensorField] = InputField(
+ default=None,
+ description="A mask defining the region that this conditioning prompt applies to.",
+ )
+
+ @torch.no_grad()
+ def invoke(self, context: InvocationContext) -> ZImageConditioningOutput:
+ prompt_embeds = self._encode_prompt(context, max_seq_len=Z_IMAGE_MAX_SEQ_LEN)
+ # Move embeddings to CPU for storage to save VRAM
+ prompt_embeds = prompt_embeds.detach().to("cpu")
+ conditioning_data = ConditioningFieldData(conditionings=[ZImageConditioningInfo(prompt_embeds=prompt_embeds)])
+ conditioning_name = context.conditioning.save(conditioning_data)
+ return ZImageConditioningOutput(
+ conditioning=ZImageConditioningField(conditioning_name=conditioning_name, mask=self.mask)
+ )
+
+ def _encode_prompt(self, context: InvocationContext, max_seq_len: int) -> torch.Tensor:
+ """Encode prompt using Qwen3 text encoder.
+
+ Based on the ZImagePipeline._encode_prompt method from diffusers.
+ """
+ prompt = self.prompt
+
+ text_encoder_info = context.models.load(self.qwen3_encoder.text_encoder)
+ tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
+
+ with ExitStack() as exit_stack:
+ (cached_weights, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
+ (_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
+
+ # Use the device that the text encoder is effectively executing on, and repair any required tensors left on
+ # the CPU by a previous interrupted run.
+ repaired_tensors = text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required Qwen3 tensor(s) onto {device} after a partial device mismatch."
+ )
+
+ # Apply LoRA models to the text encoder
+ lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
+ exit_stack.enter_context(
+ LayerPatcher.apply_smart_model_patches(
+ model=text_encoder,
+ patches=self._lora_iterator(context),
+ prefix=Z_IMAGE_LORA_QWEN3_PREFIX,
+ dtype=lora_dtype,
+ cached_weights=cached_weights,
+ )
+ )
+
+ context.util.signal_progress("Running Qwen3 text encoder")
+ if not isinstance(text_encoder, PreTrainedModel):
+ raise TypeError(
+ f"Expected PreTrainedModel for text encoder, got {type(text_encoder).__name__}. "
+ "The Qwen3 encoder model may be corrupted or incompatible."
+ )
+ if not isinstance(tokenizer, PreTrainedTokenizerBase):
+ raise TypeError(
+ f"Expected PreTrainedTokenizerBase for tokenizer, got {type(tokenizer).__name__}. "
+ "The Qwen3 tokenizer may be corrupted or incompatible."
+ )
+
+ # Apply chat template similar to diffusers ZImagePipeline
+ # The chat template formats the prompt for the Qwen3 model
+ try:
+ prompt_formatted = tokenizer.apply_chat_template(
+ [{"role": "user", "content": prompt}],
+ tokenize=False,
+ add_generation_prompt=True,
+ enable_thinking=True,
+ )
+ except (AttributeError, TypeError) as e:
+ # Fallback if tokenizer doesn't support apply_chat_template or enable_thinking
+ context.logger.warning(f"Chat template failed ({e}), using raw prompt.")
+ prompt_formatted = prompt
+
+ # Tokenize the formatted prompt
+ text_inputs = tokenizer(
+ prompt_formatted,
+ padding="max_length",
+ max_length=max_seq_len,
+ truncation=True,
+ return_attention_mask=True,
+ return_tensors="pt",
+ )
+
+ text_input_ids = text_inputs.input_ids
+ attention_mask = text_inputs.attention_mask
+ if not isinstance(text_input_ids, torch.Tensor):
+ raise TypeError(
+ f"Expected torch.Tensor for input_ids, got {type(text_input_ids).__name__}. "
+ "Tokenizer returned unexpected type."
+ )
+ if not isinstance(attention_mask, torch.Tensor):
+ raise TypeError(
+ f"Expected torch.Tensor for attention_mask, got {type(attention_mask).__name__}. "
+ "Tokenizer returned unexpected type."
+ )
+
+ # Check for truncation
+ untruncated_ids = tokenizer(prompt_formatted, padding="longest", return_tensors="pt").input_ids
+ if untruncated_ids.shape[-1] >= text_input_ids.shape[-1] and not torch.equal(
+ text_input_ids, untruncated_ids
+ ):
+ removed_text = tokenizer.batch_decode(untruncated_ids[:, max_seq_len - 1 : -1])
+ context.logger.warning(
+ f"The following part of your input was truncated because `max_sequence_length` is set to "
+ f"{max_seq_len} tokens: {removed_text}"
+ )
+
+ # Get hidden states from the text encoder
+ # Use the second-to-last hidden state like diffusers does
+ prompt_mask = attention_mask.to(device).bool()
+ outputs = text_encoder(
+ text_input_ids.to(device),
+ attention_mask=prompt_mask,
+ output_hidden_states=True,
+ )
+
+ # Validate hidden_states output
+ if not hasattr(outputs, "hidden_states") or outputs.hidden_states is None:
+ raise RuntimeError(
+ "Text encoder did not return hidden_states. "
+ "Ensure output_hidden_states=True is supported by this model."
+ )
+ if len(outputs.hidden_states) < 2:
+ raise RuntimeError(
+ f"Expected at least 2 hidden states from text encoder, got {len(outputs.hidden_states)}. "
+ "This may indicate an incompatible model or configuration."
+ )
+ prompt_embeds = outputs.hidden_states[-2]
+
+ # Z-Image expects a 2D tensor [seq_len, hidden_dim] with only valid tokens
+ # Based on diffusers ZImagePipeline implementation:
+ # embeddings_list.append(prompt_embeds[i][prompt_masks[i]])
+ # Since batch_size=1, we take the first item and filter by mask
+ prompt_embeds = prompt_embeds[0][prompt_mask[0]]
+
+ if not isinstance(prompt_embeds, torch.Tensor):
+ raise TypeError(
+ f"Expected torch.Tensor for prompt embeddings, got {type(prompt_embeds).__name__}. "
+ "Text encoder returned unexpected type."
+ )
+ return prompt_embeds
+
+ def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
+ """Iterate over LoRA models to apply to the Qwen3 text encoder."""
+ for lora in self.qwen3_encoder.loras:
+ lora_info = context.models.load(lora.lora)
+ if not isinstance(lora_info.model, ModelPatchRaw):
+ raise TypeError(
+ f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}. "
+ "The LoRA model may be corrupted or incompatible."
+ )
+ yield (lora_info.model, lora.weight)
+ del lora_info
diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py
index 701f1dab739..389b61e7347 100644
--- a/invokeai/app/run_app.py
+++ b/invokeai/app/run_app.py
@@ -1,12 +1,145 @@
-"""This is a wrapper around the main app entrypoint, to allow for CLI args to be parsed before running the app."""
+def get_app():
+ """Import the app and event loop. We wrap this in a function to more explicitly control when it happens, because
+ importing from api_app does a bunch of stuff - it's more like calling a function than importing a module.
+ """
+ from invokeai.app.api_app import app, loop
+
+ return app, loop
def run_app() -> None:
- # Before doing _anything_, parse CLI args!
+ """The main entrypoint for the app."""
+ import asyncio
+ import sys
+ import threading
+ import traceback
+
from invokeai.frontend.cli.arg_parser import InvokeAIArgs
+ # Parse the CLI arguments before doing anything else, which ensures CLI args correctly override settings from other
+ # sources like `invokeai.yaml` or env vars.
InvokeAIArgs.parse_args()
- from invokeai.app.api_app import invoke_api
+ import uvicorn
+
+ from invokeai.app.services.config.config_default import get_config
+ from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ # Load config.
+ app_config = get_config()
+
+ logger = InvokeAILogger.get_logger(config=app_config)
+
+ # Configure the torch CUDA memory allocator.
+ # NOTE: It is important that this happens before torch is imported.
+ if app_config.pytorch_cuda_alloc_conf:
+ configure_torch_cuda_allocator(app_config.pytorch_cuda_alloc_conf, logger)
+
+ # This import must happen after configure_torch_cuda_allocator() is called, because the module imports torch.
+ from invokeai.app.invocations.baseinvocation import InvocationRegistry
+ from invokeai.app.invocations.load_custom_nodes import load_custom_nodes
+ from invokeai.backend.util.devices import TorchDevice
+
+ torch_device_name = TorchDevice.get_generation_devices_summary(app_config.generation_devices)
+ logger.info(f"Using torch device: {torch_device_name}")
+
+ # Import from startup_utils here to avoid importing torch before configure_torch_cuda_allocator() is called.
+ from invokeai.app.util.startup_utils import (
+ apply_monkeypatches,
+ check_cudnn,
+ enable_dev_reload,
+ find_open_port,
+ register_mime_types,
+ )
+
+ # Find an open port, and modify the config accordingly.
+ first_open_port = find_open_port(app_config.port)
+ if app_config.port != first_open_port:
+ orig_config_port = app_config.port
+ app_config.port = first_open_port
+ logger.warning(f"Port {orig_config_port} is already in use. Using port {app_config.port}.")
+
+ # Miscellaneous startup tasks.
+ apply_monkeypatches()
+ register_mime_types()
+ check_cudnn(logger)
+
+ # Initialize the app and event loop.
+ app, loop = get_app()
+
+ # Load custom nodes. This must be done after importing the Graph class, which itself imports all modules from the
+ # invocations module. The ordering here is implicit, but important - we want to load custom nodes after all the
+ # core nodes have been imported so that we can catch when a custom node clobbers a core node.
+ load_custom_nodes(custom_nodes_path=app_config.custom_nodes_path, logger=logger)
+
+ # Check all invocations and ensure their outputs are registered.
+ for invocation in InvocationRegistry.get_invocation_classes():
+ invocation_type = invocation.get_type()
+ output_annotation = invocation.get_output_annotation()
+ if output_annotation not in InvocationRegistry.get_output_classes():
+ logger.warning(
+ f'Invocation "{invocation_type}" has unregistered output class "{output_annotation.__name__}"'
+ )
+
+ if app_config.dev_reload:
+ # load_custom_nodes seems to bypass jurrigged's import sniffer, so be sure to call it *after* they're already
+ # imported.
+ enable_dev_reload(custom_nodes_path=app_config.custom_nodes_path)
+
+ # Start the server.
+ config = uvicorn.Config(
+ app=app,
+ host=app_config.host,
+ port=app_config.port,
+ loop="asyncio",
+ log_level=app_config.log_level_network,
+ ssl_certfile=app_config.ssl_certfile,
+ ssl_keyfile=app_config.ssl_keyfile,
+ )
+ server = uvicorn.Server(config)
+
+ # replace uvicorn's loggers with InvokeAI's for consistent appearance
+ uvicorn_logger = InvokeAILogger.get_logger("uvicorn")
+ uvicorn_logger.handlers.clear()
+ for hdlr in logger.handlers:
+ uvicorn_logger.addHandler(hdlr)
+
+ try:
+ loop.run_until_complete(server.serve())
+ except KeyboardInterrupt:
+ logger.info("InvokeAI shutting down...")
+ # Gracefully shut down services (e.g. model download and install managers) so that any
+ # active work is completed or cleanly cancelled before the process exits.
+ from invokeai.app.api.dependencies import ApiDependencies
+
+ ApiDependencies.shutdown()
+
+ # Cancel any pending asyncio tasks (e.g. socket.io ping tasks) so that loop.close() does
+ # not emit "Task was destroyed but it is pending!" warnings for each one.
+ pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
+ for task in pending:
+ task.cancel()
+ if pending:
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
+
+ # Shut down the asyncio default thread executor. asyncio.to_thread() (used e.g. in the
+ # session queue for SQLite operations during generation) creates non-daemon threads via the
+ # event loop's default ThreadPoolExecutor. Without this call those threads remain alive and
+ # cause threading._shutdown() to hang indefinitely after the process's main code finishes.
+ loop.run_until_complete(loop.shutdown_default_executor())
+ loop.close()
- invoke_api()
+ # After graceful shutdown, log any non-daemon threads that are still alive. These are the
+ # threads that will cause Python's threading._shutdown() to block, preventing the process
+ # from exiting cleanly. This helps identify threads that need to be fixed or joined.
+ frames = sys._current_frames()
+ for thread in threading.enumerate():
+ if thread.daemon or thread is threading.main_thread():
+ continue
+ frame = frames.get(thread.ident)
+ stack = "".join(traceback.format_stack(frame)) if frame else "(no frame available)"
+ logger.warning(
+ f"Non-daemon thread still alive after shutdown: {thread.name!r} "
+ f"(ident={thread.ident})\nStack trace:\n{stack}"
+ )
diff --git a/invokeai/app/services/app_settings/__init__.py b/invokeai/app/services/app_settings/__init__.py
new file mode 100644
index 00000000000..0345874c11f
--- /dev/null
+++ b/invokeai/app/services/app_settings/__init__.py
@@ -0,0 +1,5 @@
+"""App settings service exports."""
+
+from invokeai.app.services.app_settings.app_settings_service import AppSettingsService
+
+__all__ = ["AppSettingsService"]
diff --git a/invokeai/app/services/app_settings/app_settings_service.py b/invokeai/app/services/app_settings/app_settings_service.py
new file mode 100644
index 00000000000..5580709ef65
--- /dev/null
+++ b/invokeai/app/services/app_settings/app_settings_service.py
@@ -0,0 +1,74 @@
+"""Service for managing application-level settings stored in the database."""
+
+from typing import Optional
+
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class AppSettingsService:
+ """Service for accessing application-level settings from the database.
+
+ This service provides a simple key-value store for application-level configuration
+ that needs to be persisted across restarts, such as JWT secrets.
+ """
+
+ def __init__(self, db: SqliteDatabase) -> None:
+ """Initialize the app settings service.
+
+ Args:
+ db: The SQLite database instance
+ """
+ self._db = db
+
+ def get(self, key: str) -> Optional[str]:
+ """Get a setting value by key.
+
+ Args:
+ key: The setting key
+
+ Returns:
+ The setting value if found, None otherwise
+ """
+ try:
+ with self._db.transaction() as cursor:
+ cursor.execute("SELECT value FROM app_settings WHERE key = ?;", (key,))
+ row = cursor.fetchone()
+ return row[0] if row else None
+ except Exception:
+ return None
+
+ def set(self, key: str, value: str) -> None:
+ """Set a setting value.
+
+ Args:
+ key: The setting key
+ value: The setting value
+ """
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ INSERT INTO app_settings (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW');
+ """,
+ (key, value),
+ )
+
+ def get_jwt_secret(self) -> str:
+ """Get the JWT secret key from the database.
+
+ Returns:
+ The JWT secret key
+
+ Raises:
+ RuntimeError: If the JWT secret is not found in the database
+ """
+ secret = self.get("jwt_secret")
+ if secret is None:
+ raise RuntimeError(
+ "JWT secret not found in database. This should have been created during database migration. "
+ "Please ensure database migrations have been run successfully."
+ )
+ return secret
diff --git a/invokeai/app/services/auth/__init__.py b/invokeai/app/services/auth/__init__.py
new file mode 100644
index 00000000000..099a5e7da1b
--- /dev/null
+++ b/invokeai/app/services/auth/__init__.py
@@ -0,0 +1 @@
+"""Authentication service module."""
diff --git a/invokeai/app/services/auth/password_utils.py b/invokeai/app/services/auth/password_utils.py
new file mode 100644
index 00000000000..b960af5f1c5
--- /dev/null
+++ b/invokeai/app/services/auth/password_utils.py
@@ -0,0 +1,113 @@
+"""Password hashing and validation utilities."""
+
+from typing import Literal, cast
+
+from passlib.context import CryptContext
+
+# Configure bcrypt context - set truncate_error=False to allow passwords >72 bytes
+# without raising an error. They will be automatically truncated by bcrypt to 72 bytes.
+pwd_context = CryptContext(
+ schemes=["bcrypt"],
+ deprecated="auto",
+ bcrypt__truncate_error=False,
+)
+
+
+def hash_password(password: str) -> str:
+ """Hash a password using bcrypt.
+
+ bcrypt has a maximum password length of 72 bytes. Longer passwords
+ are automatically truncated to comply with this limit.
+
+ Args:
+ password: The plain text password to hash
+
+ Returns:
+ The hashed password
+ """
+ # bcrypt has a 72 byte limit - encode and truncate if necessary
+ password_bytes = password.encode("utf-8")
+ if len(password_bytes) > 72:
+ # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences
+ password = password_bytes[:72].decode("utf-8", errors="ignore")
+ return cast(str, pwd_context.hash(password))
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ """Verify a password against a hash.
+
+ bcrypt has a maximum password length of 72 bytes. Longer passwords
+ are automatically truncated to match hash_password behavior.
+
+ Args:
+ plain_password: The plain text password to verify
+ hashed_password: The hashed password to verify against
+
+ Returns:
+ True if the password matches the hash, False otherwise
+ """
+ try:
+ # bcrypt has a 72 byte limit - encode and truncate if necessary to match hash_password
+ password_bytes = plain_password.encode("utf-8")
+ if len(password_bytes) > 72:
+ # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences
+ plain_password = password_bytes[:72].decode("utf-8", errors="ignore")
+ return cast(bool, pwd_context.verify(plain_password, hashed_password))
+ except Exception:
+ # Invalid hash format or other error - return False
+ return False
+
+
+def validate_password_strength(password: str) -> tuple[bool, str]:
+ """Validate password meets minimum security requirements.
+
+ Password requirements:
+ - At least 8 characters long
+ - Contains at least one uppercase letter
+ - Contains at least one lowercase letter
+ - Contains at least one digit
+
+ Args:
+ password: The password to validate
+
+ Returns:
+ A tuple of (is_valid, error_message). If valid, error_message is empty.
+ """
+ if len(password) < 8:
+ return False, "Password must be at least 8 characters long"
+
+ has_upper = any(c.isupper() for c in password)
+ has_lower = any(c.islower() for c in password)
+ has_digit = any(c.isdigit() for c in password)
+
+ if not (has_upper and has_lower and has_digit):
+ return False, "Password must contain uppercase, lowercase, and numbers"
+
+ return True, ""
+
+
+def get_password_strength(password: str) -> Literal["weak", "moderate", "strong"]:
+ """Determine the strength of a password.
+
+ Strength levels:
+ - weak: less than 8 characters
+ - moderate: 8+ characters but missing at least one of uppercase, lowercase, or digit
+ - strong: 8+ characters with uppercase, lowercase, and digit
+
+ Args:
+ password: The password to evaluate
+
+ Returns:
+ One of "weak", "moderate", or "strong"
+ """
+ if len(password) < 8:
+ return "weak"
+
+ has_upper = any(c.isupper() for c in password)
+ has_lower = any(c.islower() for c in password)
+ has_digit = any(c.isdigit() for c in password)
+
+ if not (has_upper and has_lower and has_digit):
+ return "moderate"
+
+ return "strong"
diff --git a/invokeai/app/services/auth/token_service.py b/invokeai/app/services/auth/token_service.py
new file mode 100644
index 00000000000..2d766bb90aa
--- /dev/null
+++ b/invokeai/app/services/auth/token_service.py
@@ -0,0 +1,106 @@
+"""JWT token generation and validation."""
+
+from datetime import datetime, timedelta, timezone
+from typing import cast
+
+from jose import JWTError, jwt
+from pydantic import BaseModel
+
+ALGORITHM = "HS256"
+DEFAULT_EXPIRATION_HOURS = 24
+
+# Module-level variable to store the JWT secret. This is set during application initialization
+# by calling set_jwt_secret(). The secret is loaded from the database where it is stored
+# securely after being generated during database migration.
+_jwt_secret: str | None = None
+
+
+class TokenData(BaseModel):
+ """Data stored in JWT token."""
+
+ user_id: str
+ email: str
+ is_admin: bool
+ remember_me: bool = False
+
+
+def set_jwt_secret(secret: str) -> None:
+ """Set the JWT secret key for token signing and verification.
+
+ This should be called once during application initialization with the secret
+ loaded from the database.
+
+ Args:
+ secret: The JWT secret key
+ """
+ global _jwt_secret
+ _jwt_secret = secret
+
+
+def get_jwt_secret() -> str:
+ """Get the JWT secret key.
+
+ Returns:
+ The JWT secret key
+
+ Raises:
+ RuntimeError: If the secret has not been initialized
+ """
+ if _jwt_secret is None:
+ raise RuntimeError("JWT secret has not been initialized. Call set_jwt_secret() during application startup.")
+ return _jwt_secret
+
+
+def create_access_token(data: TokenData, expires_delta: timedelta | None = None) -> str:
+ """Create a JWT access token.
+
+ Args:
+ data: The token data to encode
+ expires_delta: Optional expiration time delta. Defaults to 24 hours.
+
+ Returns:
+ The encoded JWT token
+ """
+ to_encode = data.model_dump()
+ expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=DEFAULT_EXPIRATION_HOURS))
+ to_encode.update({"exp": expire})
+ return cast(str, jwt.encode(to_encode, get_jwt_secret(), algorithm=ALGORITHM))
+
+
+def verify_token(token: str) -> TokenData | None:
+ """Verify and decode a JWT token.
+
+ Args:
+ token: The JWT token to verify
+
+ Returns:
+ TokenData if valid, None if invalid or expired
+ """
+ try:
+ # python-jose 3.5.0 has a bug where exp verification doesn't work properly
+ # We need to manually check expiration, but MUST verify signature first
+ # to prevent accepting tokens with valid payloads but invalid signatures
+
+ # First, verify the signature - this will raise JWTError if signature is invalid
+ # Note: python-jose won't reject expired tokens here due to the bug
+ payload = jwt.decode(
+ token,
+ get_jwt_secret(),
+ algorithms=[ALGORITHM],
+ )
+
+ # Now manually check expiration (because python-jose 3.5.0 doesn't do this properly)
+ if "exp" in payload:
+ exp_timestamp = payload["exp"]
+ current_timestamp = datetime.now(timezone.utc).timestamp()
+ if current_timestamp >= exp_timestamp:
+ # Token is expired
+ return None
+
+ return TokenData(**payload)
+ except JWTError:
+ # Token is invalid (bad signature, malformed, etc.)
+ return None
+ except Exception:
+ # Catch any other exceptions (e.g., Pydantic validation errors)
+ return None
diff --git a/invokeai/app/services/board_image_records/board_image_records_base.py b/invokeai/app/services/board_image_records/board_image_records_base.py
index c8f7b359085..4ccbaa952db 100644
--- a/invokeai/app/services/board_image_records/board_image_records_base.py
+++ b/invokeai/app/services/board_image_records/board_image_records_base.py
@@ -1,6 +1,8 @@
from abc import ABC, abstractmethod
from typing import Optional
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+
class BoardImageRecordStorageBase(ABC):
"""Abstract base class for the one-to-many board-image relationship record storage."""
@@ -26,6 +28,8 @@ def remove_image_from_board(
def get_all_board_image_names_for_board(
self,
board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass
@@ -45,3 +49,11 @@ def get_image_count_for_board(
) -> int:
"""Gets the number of images for a board."""
pass
+
+ @abstractmethod
+ def get_asset_count_for_board(
+ self,
+ board_id: str,
+ ) -> int:
+ """Gets the number of assets for a board."""
+ pass
diff --git a/invokeai/app/services/board_image_records/board_image_records_sqlite.py b/invokeai/app/services/board_image_records/board_image_records_sqlite.py
index cde810a7396..b249bb67334 100644
--- a/invokeai/app/services/board_image_records/board_image_records_sqlite.py
+++ b/invokeai/app/services/board_image_records/board_image_records_sqlite.py
@@ -1,33 +1,30 @@
import sqlite3
-import threading
from typing import Optional, cast
-from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record
+from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
+from invokeai.app.services.image_records.image_records_common import (
+ ASSETS_CATEGORIES,
+ IMAGE_CATEGORIES,
+ ImageCategory,
+ ImageRecord,
+ deserialize_image_record,
+)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
-from .board_image_records_base import BoardImageRecordStorageBase
-
class SqliteBoardImageRecordStorage(BoardImageRecordStorageBase):
- _conn: sqlite3.Connection
- _cursor: sqlite3.Cursor
- _lock: threading.RLock
-
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
- self._lock = db.lock
- self._conn = db.conn
- self._cursor = self._conn.cursor()
+ self._db = db
def add_image_to_board(
self,
board_id: str,
image_name: str,
) -> None:
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
INSERT INTO board_images (board_id, image_name)
VALUES (?, ?)
@@ -35,32 +32,19 @@ def add_image_to_board(
""",
(board_id, image_name, board_id),
)
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
def remove_image_from_board(
self,
image_name: str,
) -> None:
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
DELETE FROM board_images
WHERE image_name = ?;
""",
(image_name,),
)
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
def get_images_for_board(
self,
@@ -68,10 +52,8 @@ def get_images_for_board(
offset: int = 0,
limit: int = 10,
) -> OffsetPaginatedResults[ImageRecord]:
- # TODO: this isn't paginated yet?
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
SELECT images.*
FROM board_images
@@ -81,80 +63,128 @@ def get_images_for_board(
""",
(board_id,),
)
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
+ result = cast(list[sqlite3.Row], cursor.fetchall())
images = [deserialize_image_record(dict(r)) for r in result]
- self._cursor.execute(
+ cursor.execute(
"""--sql
SELECT COUNT(*) FROM images WHERE 1=1;
"""
)
- count = cast(int, self._cursor.fetchone()[0])
+ count = cast(int, cursor.fetchone()[0])
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
- def get_all_board_image_names_for_board(self, board_id: str) -> list[str]:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- SELECT image_name
- FROM board_images
- WHERE board_id = ?;
- """,
- (board_id,),
- )
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
- image_names = [r[0] for r in result]
- return image_names
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ def get_all_board_image_names_for_board(
+ self,
+ board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
+ ) -> list[str]:
+ with self._db.transaction() as cursor:
+ params: list[str | bool] = []
+
+ # Base query is a join between images and board_images
+ stmt = """
+ SELECT images.image_name
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1
+ """
+
+ # Handle board_id filter
+ if board_id == "none":
+ stmt += """--sql
+ AND board_images.board_id IS NULL
+ """
+ else:
+ stmt += """--sql
+ AND board_images.board_id = ?
+ """
+ params.append(board_id)
+
+ # Add the category filter
+ if categories is not None:
+ # Convert the enum values to unique list of strings
+ category_strings = [c.value for c in set(categories)]
+ # Create the correct length of placeholders
+ placeholders = ",".join("?" * len(category_strings))
+ stmt += f"""--sql
+ AND images.image_category IN ( {placeholders} )
+ """
+
+ # Unpack the included categories into the query params
+ for c in category_strings:
+ params.append(c)
+
+ # Add the is_intermediate filter
+ if is_intermediate is not None:
+ stmt += """--sql
+ AND images.is_intermediate = ?
+ """
+ params.append(is_intermediate)
+
+ # Put a ring on it
+ stmt += ";"
+
+ cursor.execute(stmt, params)
+
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ image_names = [r[0] for r in result]
+ return image_names
def get_board_for_image(
self,
image_name: str,
) -> Optional[str]:
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT board_id
- FROM board_images
- WHERE image_name = ?;
- """,
+ SELECT board_id
+ FROM board_images
+ WHERE image_name = ?;
+ """,
(image_name,),
)
- result = self._cursor.fetchone()
- if result is None:
- return None
- return cast(str, result[0])
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ result = cursor.fetchone()
+ if result is None:
+ return None
+ return cast(str, result[0])
def get_image_count_for_board(self, board_id: str) -> int:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- SELECT COUNT(*) FROM board_images WHERE board_id = ?;
- """,
- (board_id,),
+ with self._db.transaction() as cursor:
+ # Convert the enum values to unique list of strings
+ category_strings = [c.value for c in set(IMAGE_CATEGORIES)]
+ # Create the correct length of placeholders
+ placeholders = ",".join("?" * len(category_strings))
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM board_images
+ INNER JOIN images ON board_images.image_name = images.image_name
+ WHERE images.is_intermediate = FALSE AND images.image_category IN ( {placeholders} )
+ AND board_images.board_id = ?;
+ """,
+ (*category_strings, board_id),
+ )
+ count = cast(int, cursor.fetchone()[0])
+ return count
+
+ def get_asset_count_for_board(self, board_id: str) -> int:
+ with self._db.transaction() as cursor:
+ # Convert the enum values to unique list of strings
+ category_strings = [c.value for c in set(ASSETS_CATEGORIES)]
+ # Create the correct length of placeholders
+ placeholders = ",".join("?" * len(category_strings))
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM board_images
+ INNER JOIN images ON board_images.image_name = images.image_name
+ WHERE images.is_intermediate = FALSE AND images.image_category IN ( {placeholders} )
+ AND board_images.board_id = ?;
+ """,
+ (*category_strings, board_id),
)
- count = cast(int, self._cursor.fetchone()[0])
- return count
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ count = cast(int, cursor.fetchone()[0])
+ return count
diff --git a/invokeai/app/services/board_images/board_images_base.py b/invokeai/app/services/board_images/board_images_base.py
index 356ff7068b6..c16d971cd28 100644
--- a/invokeai/app/services/board_images/board_images_base.py
+++ b/invokeai/app/services/board_images/board_images_base.py
@@ -1,6 +1,8 @@
from abc import ABC, abstractmethod
from typing import Optional
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+
class BoardImagesServiceABC(ABC):
"""High-level service for board-image relationship management."""
@@ -26,6 +28,8 @@ def remove_image_from_board(
def get_all_board_image_names_for_board(
self,
board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
) -> list[str]:
"""Gets all board images for a board, as a list of the image names."""
pass
diff --git a/invokeai/app/services/board_images/board_images_default.py b/invokeai/app/services/board_images/board_images_default.py
index 85e478619c1..437495189f3 100644
--- a/invokeai/app/services/board_images/board_images_default.py
+++ b/invokeai/app/services/board_images/board_images_default.py
@@ -1,9 +1,9 @@
from typing import Optional
+from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
+from invokeai.app.services.image_records.image_records_common import ImageCategory
from invokeai.app.services.invoker import Invoker
-from .board_images_base import BoardImagesServiceABC
-
class BoardImagesService(BoardImagesServiceABC):
__invoker: Invoker
@@ -27,8 +27,14 @@ def remove_image_from_board(
def get_all_board_image_names_for_board(
self,
board_id: str,
+ categories: list[ImageCategory] | None,
+ is_intermediate: bool | None,
) -> list[str]:
- return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
+ return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
+ board_id,
+ categories,
+ is_intermediate,
+ )
def get_board_for_image(
self,
diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py
index 30f819618a4..20981f2c7d7 100644
--- a/invokeai/app/services/board_records/board_records_base.py
+++ b/invokeai/app/services/board_records/board_records_base.py
@@ -1,8 +1,8 @@
from abc import ABC, abstractmethod
+from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, BoardRecordOrderBy
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from .board_records_common import BoardChanges, BoardRecord
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardRecordStorageBase(ABC):
@@ -17,8 +17,9 @@ def delete(self, board_id: str) -> None:
def save(
self,
board_name: str,
+ user_id: str,
) -> BoardRecord:
- """Saves a board record."""
+ """Saves a board record for a specific user."""
pass
@abstractmethod
@@ -41,15 +42,25 @@ def update(
@abstractmethod
def get_many(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
+ include_archived: bool = False,
) -> OffsetPaginatedResults[BoardRecord]:
- """Gets many board records."""
+ """Gets many board records for a specific user, including shared boards. Admin users see all boards."""
pass
@abstractmethod
def get_all(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
+ include_archived: bool = False,
) -> list[BoardRecord]:
- """Gets all board records."""
+ """Gets all board records for a specific user, including shared boards. Admin users see all boards."""
pass
diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py
index d08951b499c..b263f264cb8 100644
--- a/invokeai/app/services/board_records/board_records_common.py
+++ b/invokeai/app/services/board_records/board_records_common.py
@@ -1,12 +1,25 @@
from datetime import datetime
+from enum import Enum
from typing import Optional, Union
from pydantic import BaseModel, Field
+from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import get_iso_timestamp
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+class BoardVisibility(str, Enum, metaclass=MetaEnum):
+ """The visibility options for a board."""
+
+ Private = "private"
+ """Only the board owner (and admins) can see and modify this board."""
+ Shared = "shared"
+ """All users can view this board, but only the owner (and admins) can modify it."""
+ Public = "public"
+ """All users can view this board; only the owner (and admins) can modify its structure."""
+
+
class BoardRecord(BaseModelExcludeNull):
"""Deserialized board record."""
@@ -14,6 +27,8 @@ class BoardRecord(BaseModelExcludeNull):
"""The unique ID of the board."""
board_name: str = Field(description="The name of the board.")
"""The name of the board."""
+ user_id: str = Field(description="The user ID of the board owner.")
+ """The user ID of the board owner."""
created_at: Union[datetime, str] = Field(description="The created timestamp of the board.")
"""The created timestamp of the image."""
updated_at: Union[datetime, str] = Field(description="The updated timestamp of the board.")
@@ -22,6 +37,12 @@ class BoardRecord(BaseModelExcludeNull):
"""The updated timestamp of the image."""
cover_image_name: Optional[str] = Field(default=None, description="The name of the cover image of the board.")
"""The name of the cover image of the board."""
+ archived: bool = Field(description="Whether or not the board is archived.")
+ """Whether or not the board is archived."""
+ board_visibility: BoardVisibility = Field(
+ default=BoardVisibility.Private, description="The visibility of the board."
+ )
+ """The visibility of the board (private, shared, or public)."""
def deserialize_board_record(board_dict: dict) -> BoardRecord:
@@ -31,24 +52,44 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
board_id = board_dict.get("board_id", "unknown")
board_name = board_dict.get("board_name", "unknown")
+ # Default to 'system' for backwards compatibility with boards created before multiuser support
+ user_id = board_dict.get("user_id", "system")
cover_image_name = board_dict.get("cover_image_name", "unknown")
created_at = board_dict.get("created_at", get_iso_timestamp())
updated_at = board_dict.get("updated_at", get_iso_timestamp())
deleted_at = board_dict.get("deleted_at", get_iso_timestamp())
+ archived = board_dict.get("archived", False)
+ board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value)
+ try:
+ board_visibility = BoardVisibility(board_visibility_raw)
+ except ValueError:
+ board_visibility = BoardVisibility.Private
return BoardRecord(
board_id=board_id,
board_name=board_name,
+ user_id=user_id,
cover_image_name=cover_image_name,
created_at=created_at,
updated_at=updated_at,
deleted_at=deleted_at,
+ archived=archived,
+ board_visibility=board_visibility,
)
class BoardChanges(BaseModel, extra="forbid"):
- board_name: Optional[str] = Field(default=None, description="The board's new name.")
+ board_name: Optional[str] = Field(default=None, description="The board's new name.", max_length=300)
cover_image_name: Optional[str] = Field(default=None, description="The name of the board's new cover image.")
+ archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived")
+ board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.")
+
+
+class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum):
+ """The order by options for board records"""
+
+ CreatedAt = "created_at"
+ Name = "board_name"
class BoardRecordNotFoundException(Exception):
diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py
index a3836cb6c7d..1e3e11c8a36 100644
--- a/invokeai/app/services/board_records/board_records_sqlite.py
+++ b/invokeai/app/services/board_records/board_records_sqlite.py
@@ -1,96 +1,77 @@
import sqlite3
-import threading
from typing import Union, cast
-from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
-from invokeai.app.util.misc import uuid_string
-
-from .board_records_base import BoardRecordStorageBase
-from .board_records_common import (
+from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
+from invokeai.app.services.board_records.board_records_common import (
BoardChanges,
BoardRecord,
BoardRecordDeleteException,
BoardRecordNotFoundException,
+ BoardRecordOrderBy,
BoardRecordSaveException,
deserialize_board_record,
)
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.app.util.misc import uuid_string
class SqliteBoardRecordStorage(BoardRecordStorageBase):
- _conn: sqlite3.Connection
- _cursor: sqlite3.Cursor
- _lock: threading.RLock
-
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
- self._lock = db.lock
- self._conn = db.conn
- self._cursor = self._conn.cursor()
+ self._db = db
def delete(self, board_id: str) -> None:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- DELETE FROM boards
- WHERE board_id = ?;
- """,
- (board_id,),
- )
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise BoardRecordDeleteException from e
- except Exception as e:
- self._conn.rollback()
- raise BoardRecordDeleteException from e
- finally:
- self._lock.release()
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ DELETE FROM boards
+ WHERE board_id = ?;
+ """,
+ (board_id,),
+ )
+ except Exception as e:
+ raise BoardRecordDeleteException from e
def save(
self,
board_name: str,
+ user_id: str,
) -> BoardRecord:
- try:
- board_id = uuid_string()
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- INSERT OR IGNORE INTO boards (board_id, board_name)
- VALUES (?, ?);
- """,
- (board_id, board_name),
- )
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise BoardRecordSaveException from e
- finally:
- self._lock.release()
+ with self._db.transaction() as cursor:
+ try:
+ board_id = uuid_string()
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO boards (board_id, board_name, user_id)
+ VALUES (?, ?, ?);
+ """,
+ (board_id, board_name, user_id),
+ )
+ except sqlite3.Error as e:
+ raise BoardRecordSaveException from e
return self.get(board_id)
def get(
self,
board_id: str,
) -> BoardRecord:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- SELECT *
- FROM boards
- WHERE board_id = ?;
- """,
- (board_id,),
- )
-
- result = cast(Union[sqlite3.Row, None], self._cursor.fetchone())
- except sqlite3.Error as e:
- self._conn.rollback()
- raise BoardRecordNotFoundException from e
- finally:
- self._lock.release()
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ SELECT *
+ FROM boards
+ WHERE board_id = ?;
+ """,
+ (board_id,),
+ )
+
+ result = cast(Union[sqlite3.Row, None], cursor.fetchone())
+ except sqlite3.Error as e:
+ raise BoardRecordNotFoundException from e
if result is None:
raise BoardRecordNotFoundException
return BoardRecord(**dict(result))
@@ -100,102 +81,210 @@ def update(
board_id: str,
changes: BoardChanges,
) -> BoardRecord:
- try:
- self._lock.acquire()
+ with self._db.transaction() as cursor:
+ try:
+ # Change the name of a board
+ if changes.board_name is not None:
+ cursor.execute(
+ """--sql
+ UPDATE boards
+ SET board_name = ?
+ WHERE board_id = ?;
+ """,
+ (changes.board_name, board_id),
+ )
- # Change the name of a board
- if changes.board_name is not None:
- self._cursor.execute(
- """--sql
- UPDATE boards
- SET board_name = ?
- WHERE board_id = ?;
- """,
- (changes.board_name, board_id),
- )
+ # Change the cover image of a board
+ if changes.cover_image_name is not None:
+ cursor.execute(
+ """--sql
+ UPDATE boards
+ SET cover_image_name = ?
+ WHERE board_id = ?;
+ """,
+ (changes.cover_image_name, board_id),
+ )
- # Change the cover image of a board
- if changes.cover_image_name is not None:
- self._cursor.execute(
- """--sql
- UPDATE boards
- SET cover_image_name = ?
- WHERE board_id = ?;
- """,
- (changes.cover_image_name, board_id),
- )
+ # Change the archived status of a board
+ if changes.archived is not None:
+ cursor.execute(
+ """--sql
+ UPDATE boards
+ SET archived = ?
+ WHERE board_id = ?;
+ """,
+ (changes.archived, board_id),
+ )
+
+ # Change the visibility of a board
+ if changes.board_visibility is not None:
+ cursor.execute(
+ """--sql
+ UPDATE boards
+ SET board_visibility = ?
+ WHERE board_id = ?;
+ """,
+ (changes.board_visibility.value, board_id),
+ )
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise BoardRecordSaveException from e
- finally:
- self._lock.release()
+ except sqlite3.Error as e:
+ raise BoardRecordSaveException from e
return self.get(board_id)
def get_many(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
+ include_archived: bool = False,
) -> OffsetPaginatedResults[BoardRecord]:
- try:
- self._lock.acquire()
-
- # Get all the boards
- self._cursor.execute(
- """--sql
- SELECT *
- FROM boards
- ORDER BY created_at DESC
- LIMIT ? OFFSET ?;
- """,
- (limit, offset),
- )
-
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
- boards = [deserialize_board_record(dict(r)) for r in result]
+ with self._db.transaction() as cursor:
+ # Build base query - admins see all boards, regular users see owned, shared, or public boards
+ if is_admin:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ {archived_filter}
+ ORDER BY {order_by} {direction}
+ LIMIT ? OFFSET ?;
+ """
+
+ # Determine archived filter condition
+ archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0"
- # Get the total number of boards
- self._cursor.execute(
- """--sql
- SELECT COUNT(*)
- FROM boards
- WHERE 1=1;
+ final_query = base_query.format(
+ archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
+ )
+
+ # Execute query to fetch boards
+ cursor.execute(final_query, (limit, offset))
+ else:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
+ {archived_filter}
+ ORDER BY {order_by} {direction}
+ LIMIT ? OFFSET ?;
"""
- )
- count = cast(int, self._cursor.fetchone()[0])
+ # Determine archived filter condition
+ archived_filter = "" if include_archived else "AND boards.archived = 0"
+
+ final_query = base_query.format(
+ archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
+ )
+
+ # Execute query to fetch boards
+ cursor.execute(final_query, (user_id, user_id, limit, offset))
- return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ boards = [deserialize_board_record(dict(r)) for r in result]
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ # Determine count query - admins count all boards, regular users count accessible boards
+ if is_admin:
+ if include_archived:
+ count_query = """
+ SELECT COUNT(DISTINCT boards.board_id)
+ FROM boards;
+ """
+ else:
+ count_query = """
+ SELECT COUNT(DISTINCT boards.board_id)
+ FROM boards
+ WHERE boards.archived = 0;
+ """
+ cursor.execute(count_query)
+ else:
+ if include_archived:
+ count_query = """
+ SELECT COUNT(DISTINCT boards.board_id)
+ FROM boards
+ LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'));
+ """
+ else:
+ count_query = """
+ SELECT COUNT(DISTINCT boards.board_id)
+ FROM boards
+ LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
+ AND boards.archived = 0;
+ """
+
+ # Execute count query
+ cursor.execute(count_query, (user_id, user_id))
+
+ count = cast(int, cursor.fetchone()[0])
+
+ return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count)
def get_all(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
+ include_archived: bool = False,
) -> list[BoardRecord]:
- try:
- self._lock.acquire()
-
- # Get all the boards
- self._cursor.execute(
- """--sql
- SELECT *
- FROM boards
- ORDER BY created_at DESC
- """
- )
+ with self._db.transaction() as cursor:
+ # Build query - admins see all boards, regular users see owned, shared, or public boards
+ if is_admin:
+ if order_by == BoardRecordOrderBy.Name:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ {archived_filter}
+ ORDER BY LOWER(boards.board_name) {direction}
+ """
+ else:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ {archived_filter}
+ ORDER BY {order_by} {direction}
+ """
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
- boards = [deserialize_board_record(dict(r)) for r in result]
+ archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0"
+
+ final_query = base_query.format(
+ archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
+ )
+
+ cursor.execute(final_query)
+ else:
+ if order_by == BoardRecordOrderBy.Name:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
+ {archived_filter}
+ ORDER BY LOWER(boards.board_name) {direction}
+ """
+ else:
+ base_query = """
+ SELECT DISTINCT boards.*
+ FROM boards
+ LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id
+ WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public'))
+ {archived_filter}
+ ORDER BY {order_by} {direction}
+ """
+
+ archived_filter = "" if include_archived else "AND boards.archived = 0"
+
+ final_query = base_query.format(
+ archived_filter=archived_filter, order_by=order_by.value, direction=direction.value
+ )
+
+ cursor.execute(final_query, (user_id, user_id))
- return boards
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ boards = [deserialize_board_record(dict(r)) for r in result]
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ return boards
diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py
index 6f90334d53e..914dfa3d0d7 100644
--- a/invokeai/app/services/boards/boards_base.py
+++ b/invokeai/app/services/boards/boards_base.py
@@ -1,9 +1,9 @@
from abc import ABC, abstractmethod
-from invokeai.app.services.board_records.board_records_common import BoardChanges
+from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
+from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from .boards_common import BoardDTO
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardServiceABC(ABC):
@@ -13,8 +13,9 @@ class BoardServiceABC(ABC):
def create(
self,
board_name: str,
+ user_id: str,
) -> BoardDTO:
- """Creates a board."""
+ """Creates a board for a specific user."""
pass
@abstractmethod
@@ -45,15 +46,25 @@ def delete(
@abstractmethod
def get_many(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
offset: int = 0,
limit: int = 10,
+ include_archived: bool = False,
) -> OffsetPaginatedResults[BoardDTO]:
- """Gets many boards."""
+ """Gets many boards for a specific user, including shared boards. Admin users see all boards."""
pass
@abstractmethod
def get_all(
self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
+ include_archived: bool = False,
) -> list[BoardDTO]:
- """Gets all boards."""
+ """Gets all boards for a specific user, including shared boards. Admin users see all boards."""
pass
diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py
index 0cb54102bb3..99952fec134 100644
--- a/invokeai/app/services/boards/boards_common.py
+++ b/invokeai/app/services/boards/boards_common.py
@@ -2,7 +2,7 @@
from pydantic import Field
-from ..board_records.board_records_common import BoardRecord
+from invokeai.app.services.board_records.board_records_common import BoardRecord
class BoardDTO(BoardRecord):
@@ -12,12 +12,24 @@ class BoardDTO(BoardRecord):
"""The URL of the thumbnail of the most recent image in the board."""
image_count: int = Field(description="The number of images in the board.")
"""The number of images in the board."""
+ asset_count: int = Field(description="The number of assets in the board.")
+ """The number of assets in the board."""
+ owner_username: Optional[str] = Field(default=None, description="The username of the board owner (for admin view).")
+ """The username of the board owner (for admin view)."""
-def board_record_to_dto(board_record: BoardRecord, cover_image_name: Optional[str], image_count: int) -> BoardDTO:
+def board_record_to_dto(
+ board_record: BoardRecord,
+ cover_image_name: Optional[str],
+ image_count: int,
+ asset_count: int,
+ owner_username: Optional[str] = None,
+) -> BoardDTO:
"""Converts a board record to a board DTO."""
return BoardDTO(
**board_record.model_dump(exclude={"cover_image_name"}),
cover_image_name=cover_image_name,
image_count=image_count,
+ asset_count=asset_count,
+ owner_username=owner_username,
)
diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py
index 5b37d6c7ad2..71465815ef9 100644
--- a/invokeai/app/services/boards/boards_default.py
+++ b/invokeai/app/services/boards/boards_default.py
@@ -1,10 +1,9 @@
-from invokeai.app.services.board_records.board_records_common import BoardChanges
-from invokeai.app.services.boards.boards_common import BoardDTO
+from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
+from invokeai.app.services.boards.boards_base import BoardServiceABC
+from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from .boards_base import BoardServiceABC
-from .boards_common import board_record_to_dto
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class BoardService(BoardServiceABC):
@@ -16,9 +15,10 @@ def start(self, invoker: Invoker) -> None:
def create(
self,
board_name: str,
+ user_id: str,
) -> BoardDTO:
- board_record = self.__invoker.services.board_records.save(board_name)
- return board_record_to_dto(board_record, None, 0)
+ board_record = self.__invoker.services.board_records.save(board_name, user_id)
+ return board_record_to_dto(board_record, None, 0, 0)
def get_dto(self, board_id: str) -> BoardDTO:
board_record = self.__invoker.services.board_records.get(board_id)
@@ -28,7 +28,8 @@ def get_dto(self, board_id: str) -> BoardDTO:
else:
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id)
- return board_record_to_dto(board_record, cover_image_name, image_count)
+ asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(board_id)
+ return board_record_to_dto(board_record, cover_image_name, image_count, asset_count)
def update(
self,
@@ -43,13 +44,25 @@ def update(
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id)
- return board_record_to_dto(board_record, cover_image_name, image_count)
+ asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(board_id)
+ return board_record_to_dto(board_record, cover_image_name, image_count, asset_count)
def delete(self, board_id: str) -> None:
self.__invoker.services.board_records.delete(board_id)
- def get_many(self, offset: int = 0, limit: int = 10) -> OffsetPaginatedResults[BoardDTO]:
- board_records = self.__invoker.services.board_records.get_many(offset, limit)
+ def get_many(
+ self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
+ offset: int = 0,
+ limit: int = 10,
+ include_archived: bool = False,
+ ) -> OffsetPaginatedResults[BoardDTO]:
+ board_records = self.__invoker.services.board_records.get_many(
+ user_id, is_admin, order_by, direction, offset, limit, include_archived
+ )
board_dtos = []
for r in board_records.items:
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)
@@ -59,12 +72,30 @@ def get_many(self, offset: int = 0, limit: int = 10) -> OffsetPaginatedResults[B
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id)
- board_dtos.append(board_record_to_dto(r, cover_image_name, image_count))
+ asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id)
+
+ # For admin users, include owner username
+ owner_username = None
+ if is_admin:
+ owner = self.__invoker.services.users.get(r.user_id)
+ if owner:
+ owner_username = owner.display_name or owner.email
+
+ board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username))
return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos))
- def get_all(self) -> list[BoardDTO]:
- board_records = self.__invoker.services.board_records.get_all()
+ def get_all(
+ self,
+ user_id: str,
+ is_admin: bool,
+ order_by: BoardRecordOrderBy,
+ direction: SQLiteDirection,
+ include_archived: bool = False,
+ ) -> list[BoardDTO]:
+ board_records = self.__invoker.services.board_records.get_all(
+ user_id, is_admin, order_by, direction, include_archived
+ )
board_dtos = []
for r in board_records:
cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id)
@@ -74,6 +105,15 @@ def get_all(self) -> list[BoardDTO]:
cover_image_name = None
image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id)
- board_dtos.append(board_record_to_dto(r, cover_image_name, image_count))
+ asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id)
+
+ # For admin users, include owner username
+ owner_username = None
+ if is_admin:
+ owner = self.__invoker.services.users.get(r.user_id)
+ if owner:
+ owner_username = owner.display_name or owner.email
+
+ board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username))
return board_dtos
diff --git a/invokeai/app/services/bulk_download/bulk_download_base.py b/invokeai/app/services/bulk_download/bulk_download_base.py
index 617b611f566..6cd4ed0cbaf 100644
--- a/invokeai/app/services/bulk_download/bulk_download_base.py
+++ b/invokeai/app/services/bulk_download/bulk_download_base.py
@@ -7,7 +7,11 @@ class BulkDownloadBase(ABC):
@abstractmethod
def handler(
- self, image_names: Optional[list[str]], board_id: Optional[str], bulk_download_item_id: Optional[str]
+ self,
+ image_names: Optional[list[str]],
+ board_id: Optional[str],
+ bulk_download_item_id: Optional[str],
+ user_id: str = "system",
) -> None:
"""
Create a zip file containing the images specified by the given image names or board id.
@@ -15,6 +19,7 @@ def handler(
:param image_names: A list of image names to include in the zip file.
:param board_id: The ID of the board. If provided, all images associated with the board will be included in the zip file.
:param bulk_download_item_id: The bulk_download_item_id that will be used to retrieve the bulk download item when it is prepared, if none is provided a uuid will be generated.
+ :param user_id: The ID of the user who initiated the download.
"""
@abstractmethod
@@ -42,3 +47,12 @@ def delete(self, bulk_download_item_name: str) -> None:
:param bulk_download_item_name: The name of the bulk download item.
"""
+
+ @abstractmethod
+ def get_owner(self, bulk_download_item_name: str) -> Optional[str]:
+ """
+ Get the user_id of the user who initiated the download.
+
+ :param bulk_download_item_name: The name of the bulk download item.
+ :return: The user_id of the owner, or None if not tracked.
+ """
diff --git a/invokeai/app/services/bulk_download/bulk_download_default.py b/invokeai/app/services/bulk_download/bulk_download_default.py
index d4bf059b8f0..c037e9c5c15 100644
--- a/invokeai/app/services/bulk_download/bulk_download_default.py
+++ b/invokeai/app/services/bulk_download/bulk_download_default.py
@@ -4,6 +4,7 @@
from zipfile import ZipFile
from invokeai.app.services.board_records.board_records_common import BoardRecordNotFoundException
+from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
from invokeai.app.services.bulk_download.bulk_download_common import (
DEFAULT_BULK_DOWNLOAD_ID,
BulkDownloadException,
@@ -15,8 +16,6 @@
from invokeai.app.services.invoker import Invoker
from invokeai.app.util.misc import uuid_string
-from .bulk_download_base import BulkDownloadBase
-
class BulkDownloadService(BulkDownloadBase):
def start(self, invoker: Invoker) -> None:
@@ -26,15 +25,24 @@ def __init__(self):
self._temp_directory = TemporaryDirectory()
self._bulk_downloads_folder = Path(self._temp_directory.name) / "bulk_downloads"
self._bulk_downloads_folder.mkdir(parents=True, exist_ok=True)
+ # Track which user owns each download so the fetch endpoint can enforce ownership
+ self._download_owners: dict[str, str] = {}
def handler(
- self, image_names: Optional[list[str]], board_id: Optional[str], bulk_download_item_id: Optional[str]
+ self,
+ image_names: Optional[list[str]],
+ board_id: Optional[str],
+ bulk_download_item_id: Optional[str],
+ user_id: str = "system",
) -> None:
bulk_download_id: str = DEFAULT_BULK_DOWNLOAD_ID
bulk_download_item_id = bulk_download_item_id or uuid_string()
bulk_download_item_name = bulk_download_item_id + ".zip"
- self._signal_job_started(bulk_download_id, bulk_download_item_id, bulk_download_item_name)
+ # Record ownership so the fetch endpoint can verify the caller
+ self._download_owners[bulk_download_item_name] = user_id
+
+ self._signal_job_started(bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id)
try:
image_dtos: list[ImageDTO] = []
@@ -47,16 +55,16 @@ def handler(
raise BulkDownloadParametersException()
bulk_download_item_name: str = self._create_zip_file(image_dtos, bulk_download_item_id)
- self._signal_job_completed(bulk_download_id, bulk_download_item_id, bulk_download_item_name)
+ self._signal_job_completed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id)
except (
ImageRecordNotFoundException,
BoardRecordNotFoundException,
BulkDownloadException,
BulkDownloadParametersException,
) as e:
- self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e)
+ self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e, user_id)
except Exception as e:
- self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e)
+ self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e, user_id)
self._invoker.services.logger.error("Problem bulk downloading images.")
raise e
@@ -64,7 +72,11 @@ def _image_handler(self, image_names: list[str]) -> list[ImageDTO]:
return [self._invoker.services.images.get_dto(image_name) for image_name in image_names]
def _board_handler(self, board_id: str) -> list[ImageDTO]:
- image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
+ image_names = self._invoker.services.board_image_records.get_all_board_image_names_for_board(
+ board_id,
+ categories=None,
+ is_intermediate=None,
+ )
return self._image_handler(image_names)
def generate_item_id(self, board_id: Optional[str]) -> str:
@@ -100,43 +112,60 @@ def _clean_string_to_path_safe(self, s: str) -> str:
return "".join([c for c in s if c.isalpha() or c.isdigit() or c == " " or c == "_" or c == "-"]).rstrip()
def _signal_job_started(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> None:
"""Signal that a bulk download job has started."""
if self._invoker:
assert bulk_download_id is not None
self._invoker.services.events.emit_bulk_download_started(
- bulk_download_id, bulk_download_item_id, bulk_download_item_name
+ bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id=user_id
)
def _signal_job_completed(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> None:
"""Signal that a bulk download job has completed."""
if self._invoker:
assert bulk_download_id is not None
assert bulk_download_item_name is not None
self._invoker.services.events.emit_bulk_download_complete(
- bulk_download_id, bulk_download_item_id, bulk_download_item_name
+ bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id=user_id
)
def _signal_job_failed(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, exception: Exception
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ exception: Exception,
+ user_id: str = "system",
) -> None:
"""Signal that a bulk download job has failed."""
if self._invoker:
assert bulk_download_id is not None
assert exception is not None
self._invoker.services.events.emit_bulk_download_error(
- bulk_download_id, bulk_download_item_id, bulk_download_item_name, str(exception)
+ bulk_download_id, bulk_download_item_id, bulk_download_item_name, str(exception), user_id=user_id
)
def stop(self, *args, **kwargs):
self._temp_directory.cleanup()
+ def get_owner(self, bulk_download_item_name: str) -> Optional[str]:
+ return self._download_owners.get(bulk_download_item_name)
+
def delete(self, bulk_download_item_name: str) -> None:
path = self.get_path(bulk_download_item_name)
Path(path).unlink()
+ self._download_owners.pop(bulk_download_item_name, None)
def get_path(self, bulk_download_item_name: str) -> str:
path = str(self._bulk_downloads_folder / bulk_download_item_name)
@@ -147,4 +176,15 @@ def get_path(self, bulk_download_item_name: str) -> str:
def _is_valid_path(self, path: Union[str, Path]) -> bool:
"""Validates the path given for a bulk download."""
path = path if isinstance(path, Path) else Path(path)
- return path.exists()
+
+ # Resolve the path to handle any path traversal attempts (e.g., ../)
+ resolved_path = path.resolve()
+
+ # The path may not traverse out of the bulk downloads folder or its subfolders
+ does_not_traverse = resolved_path.parent == self._bulk_downloads_folder.resolve()
+
+ # The path must exist and be a .zip file
+ does_exist = resolved_path.exists()
+ is_zip_file = resolved_path.suffix == ".zip"
+
+ return does_exist and is_zip_file and does_not_traverse
diff --git a/invokeai/app/services/client_state_persistence/client_state_persistence_base.py b/invokeai/app/services/client_state_persistence/client_state_persistence_base.py
new file mode 100644
index 00000000000..7be6841a790
--- /dev/null
+++ b/invokeai/app/services/client_state_persistence/client_state_persistence_base.py
@@ -0,0 +1,72 @@
+from abc import ABC, abstractmethod
+
+
+class ClientStatePersistenceABC(ABC):
+ """
+ Base class for client persistence implementations.
+ This class defines the interface for persisting client data per user.
+ """
+
+ @abstractmethod
+ def set_by_key(self, user_id: str, key: str, value: str) -> str:
+ """
+ Set a key-value pair for the client.
+
+ Args:
+ user_id (str): The user ID to set state for.
+ key (str): The key to set.
+ value (str): The value to set for the key.
+
+ Returns:
+ str: The value that was set.
+ """
+ pass
+
+ @abstractmethod
+ def get_by_key(self, user_id: str, key: str) -> str | None:
+ """
+ Get the value for a specific key of the client.
+
+ Args:
+ user_id (str): The user ID to get state for.
+ key (str): The key to retrieve the value for.
+
+ Returns:
+ str | None: The value associated with the key, or None if the key does not exist.
+ """
+ pass
+
+ @abstractmethod
+ def get_keys_by_prefix(self, user_id: str, prefix: str) -> list[str]:
+ """
+ Get all keys matching a prefix for a user.
+
+ Args:
+ user_id (str): The user ID to get keys for.
+ prefix (str): The prefix to filter keys by.
+
+ Returns:
+ list[str]: A list of keys matching the prefix.
+ """
+ pass
+
+ @abstractmethod
+ def delete_by_key(self, user_id: str, key: str) -> None:
+ """
+ Delete a specific key-value pair for a user.
+
+ Args:
+ user_id (str): The user ID to delete state for.
+ key (str): The key to delete.
+ """
+ pass
+
+ @abstractmethod
+ def delete(self, user_id: str) -> None:
+ """
+ Delete all client state for a user.
+
+ Args:
+ user_id (str): The user ID to delete state for.
+ """
+ pass
diff --git a/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py b/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py
new file mode 100644
index 00000000000..7605de829d9
--- /dev/null
+++ b/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py
@@ -0,0 +1,80 @@
+from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class ClientStatePersistenceSqlite(ClientStatePersistenceABC):
+ """
+ SQLite implementation for client state persistence.
+ This class stores client state data per user to prevent data leakage between users.
+ """
+
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def start(self, invoker: Invoker) -> None:
+ self._invoker = invoker
+
+ def set_by_key(self, user_id: str, key: str, value: str) -> str:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ INSERT INTO client_state (user_id, key, value)
+ VALUES (?, ?, ?)
+ ON CONFLICT(user_id, key) DO UPDATE
+ SET value = excluded.value;
+ """,
+ (user_id, key, value),
+ )
+
+ return value
+
+ def get_by_key(self, user_id: str, key: str) -> str | None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT value FROM client_state
+ WHERE user_id = ? AND key = ?
+ """,
+ (user_id, key),
+ )
+ row = cursor.fetchone()
+ if row is None:
+ return None
+ return row[0]
+
+ def get_keys_by_prefix(self, user_id: str, prefix: str) -> list[str]:
+ # Escape LIKE wildcards (%, _) and the escape char itself so callers can pass
+ # arbitrary strings as a literal prefix without accidental pattern matching.
+ escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT key FROM client_state
+ WHERE user_id = ? AND key LIKE ? ESCAPE '\\'
+ ORDER BY updated_at DESC
+ """,
+ (user_id, f"{escaped_prefix}%"),
+ )
+ return [row[0] for row in cursor.fetchall()]
+
+ def delete_by_key(self, user_id: str, key: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ DELETE FROM client_state
+ WHERE user_id = ? AND key = ?
+ """,
+ (user_id, key),
+ )
+
+ def delete(self, user_id: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ DELETE FROM client_state
+ WHERE user_id = ?
+ """,
+ (user_id,),
+ )
diff --git a/invokeai/app/services/config/__init__.py b/invokeai/app/services/config/__init__.py
index 126692f08a8..df1acbf1047 100644
--- a/invokeai/app/services/config/__init__.py
+++ b/invokeai/app/services/config/__init__.py
@@ -1,7 +1,6 @@
"""Init file for InvokeAI configure package."""
from invokeai.app.services.config.config_common import PagingArgumentParser
-
-from .config_default import InvokeAIAppConfig, get_config
+from invokeai.app.services.config.config_default import InvokeAIAppConfig, get_config
__all__ = ["InvokeAIAppConfig", "get_config", "PagingArgumentParser"]
diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py
index 1dc75add1d6..d5c8a9634a5 100644
--- a/invokeai/app/services/config/config_default.py
+++ b/invokeai/app/services/config/config_default.py
@@ -3,15 +3,16 @@
from __future__ import annotations
+import copy
+import filecmp
import locale
import os
import re
import shutil
from functools import lru_cache
from pathlib import Path
-from typing import Any, Literal, Optional
+from typing import Any, Literal, Optional, Union
-import psutil
import yaml
from pydantic import BaseModel, Field, PrivateAttr, field_validator
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
@@ -21,36 +22,26 @@
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")
-DEFAULT_RAM_CACHE = 10.0
-DEFAULT_VRAM_CACHE = 0.25
-DEFAULT_CONVERT_CACHE = 20.0
-DEVICE = Literal["auto", "cpu", "cuda", "cuda:1", "mps"]
PRECISION = Literal["auto", "float16", "bfloat16", "float32"]
ATTENTION_TYPE = Literal["auto", "normal", "xformers", "sliced", "torch-sdp"]
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.1"
-
-
-def get_default_ram_cache_size() -> float:
- """Run a heuristic for the default RAM cache based on installed RAM."""
-
- # On some machines, psutil.virtual_memory().total gives a value that is slightly less than the actual RAM, so the
- # limits are set slightly lower than than what we expect the actual RAM to be.
-
- GB = 1024**3
- max_ram = psutil.virtual_memory().total / GB
-
- if max_ram >= 60:
- return 15.0
- if max_ram >= 30:
- return 7.5
- if max_ram >= 14:
- return 4.0
- return 2.1 # 2.1 is just large enough for sd 1.5 ;-)
+IMAGE_SUBFOLDER_STRATEGY = Literal["flat", "date", "type", "hash"]
+CONFIG_SCHEMA_VERSION = "4.0.3"
+EXTERNAL_PROVIDER_CONFIG_FIELDS = (
+ "external_alibabacloud_api_key",
+ "external_alibabacloud_base_url",
+ "external_gemini_api_key",
+ "external_gemini_base_url",
+ "external_openai_api_key",
+ "external_openai_base_url",
+ "external_seedream_api_key",
+ "external_seedream_base_url",
+)
class URLRegexTokenPair(BaseModel):
@@ -80,32 +71,42 @@ class InvokeAIAppConfig(BaseSettings):
allow_credentials: Allow CORS credentials.
allow_methods: Methods allowed for CORS.
allow_headers: Headers allowed for CORS.
- ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.org/settings/#https.
- ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.org/settings/#https.
+ ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.
+ ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.
log_tokenization: Enable logging of parsed prompt tokens.
patchmatch: Enable patchmatch inpaint code.
models_dir: Path to the models directory.
- convert_cache_dir: Path to the converted models cache directory. When loading a non-diffusers model, it will be converted and store on disk at this location.
+ convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).
download_cache_dir: Path to the directory that contains dynamically downloaded models.
legacy_conf_dir: Path to directory of legacy checkpoint config files.
db_dir: Path to InvokeAI databases directory.
outputs_dir: Path to directory for outputs.
+ image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance. Valid values: `flat`, `date`, `type`, `hash`
custom_nodes_dir: Path to directory for custom nodes.
+ style_presets_dir: Path to directory for style presets.
+ workflow_thumbnails_dir: Path to directory for workflow thumbnails.
log_handlers: Log handler. Valid options are "console", "file=", "syslog=path|address:host:port", "http=".
log_format: Log format. Use "plain" for text-only, "color" for colorized output, "legacy" for 2.3-style logging and "syslog" for syslog-style. Valid values: `plain`, `color`, `syslog`, `legacy`
log_level: Emit logging messages at this level or higher. Valid values: `debug`, `info`, `warning`, `error`, `critical`
log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.
+ log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose. Valid values: `debug`, `info`, `warning`, `error`, `critical`
use_memory_db: Use in-memory database. Useful for development.
dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.
profile_graphs: Enable graph profiling using `cProfile`.
profile_prefix: An optional prefix for profile output files.
profiles_dir: Path to profiles output directory.
- ram: Maximum memory amount used by memory model cache for rapid switching (GB).
- vram: Amount of VRAM reserved for model storage (GB).
- convert_cache: Maximum size of on-disk converted models cache (GB).
- lazy_offload: Keep models in VRAM until their space is needed.
+ max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.
+ max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.
log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.
- device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities. Valid values: `auto`, `cpu`, `cuda`, `cuda:1`, `mps`
+ model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.
+ device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.
+ enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.
+ keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.
+ ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.
+ vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.
+ lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.
+ pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to "backend:cudaMallocAsync" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.
+ device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities. Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)
precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system. Valid values: `auto`, `float16`, `bfloat16`, `float32`
sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.
attention_type: Attention type. Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`
@@ -113,13 +114,26 @@ class InvokeAIAppConfig(BaseSettings):
force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).
pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.
max_queue_size: Maximum number of items in the session queue.
- clear_queue_on_startup: Empties session queue on startup.
+ clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.
+ max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.
allow_nodes: List of nodes to allow. Omit to allow all.
deny_nodes: List of nodes to deny. Omit to deny none.
node_cache_size: How many cached nodes to keep in memory.
hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3. Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`
remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.
scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.
+ unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.
+ 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_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.
+ external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.
+ 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.
+ external_seedream_api_key: API key for Seedream image generation.
+ external_seedream_base_url: Base URL override for Seedream image generation.
"""
_root: Optional[Path] = PrivateAttr(default=None)
@@ -139,8 +153,8 @@ class InvokeAIAppConfig(BaseSettings):
allow_credentials: bool = Field(default=True, description="Allow CORS credentials.")
allow_methods: list[str] = Field(default=["*"], description="Methods allowed for CORS.")
allow_headers: list[str] = Field(default=["*"], description="Headers allowed for CORS.")
- ssl_certfile: Optional[Path] = Field(default=None, description="SSL certificate file for HTTPS. See https://www.uvicorn.org/settings/#https.")
- ssl_keyfile: Optional[Path] = Field(default=None, description="SSL key file for HTTPS. See https://www.uvicorn.org/settings/#https.")
+ ssl_certfile: Optional[Path] = Field(default=None, description="SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.")
+ ssl_keyfile: Optional[Path] = Field(default=None, description="SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.")
# MISC FEATURES
log_tokenization: bool = Field(default=False, description="Enable logging of parsed prompt tokens.")
@@ -148,12 +162,15 @@ class InvokeAIAppConfig(BaseSettings):
# PATHS
models_dir: Path = Field(default=Path("models"), description="Path to the models directory.")
- convert_cache_dir: Path = Field(default=Path("models/.convert_cache"), description="Path to the converted models cache directory. When loading a non-diffusers model, it will be converted and store on disk at this location.")
+ convert_cache_dir: Path = Field(default=Path("models/.convert_cache"), description="Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).")
download_cache_dir: Path = Field(default=Path("models/.download_cache"), description="Path to the directory that contains dynamically downloaded models.")
legacy_conf_dir: Path = Field(default=Path("configs"), description="Path to directory of legacy checkpoint config files.")
db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.")
outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.")
+ image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY = Field(default="flat", description="Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.")
custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.")
+ style_presets_dir: Path = Field(default=Path("style_presets"), description="Path to directory for style presets.")
+ workflow_thumbnails_dir: Path = Field(default=Path("workflow_thumbnails"), description="Path to directory for workflow thumbnails.")
# LOGGING
log_handlers: list[str] = Field(default=["console"], description='Log handler. Valid options are "console", "file=", "syslog=path|address:host:port", "http=".')
@@ -161,6 +178,7 @@ class InvokeAIAppConfig(BaseSettings):
log_format: LOG_FORMAT = Field(default="color", description='Log format. Use "plain" for text-only, "color" for colorized output, "legacy" for 2.3-style logging and "syslog" for syslog-style.')
log_level: LOG_LEVEL = Field(default="info", description="Emit logging messages at this level or higher.")
log_sql: bool = Field(default=False, description="Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.")
+ log_level_network: LOG_LEVEL = Field(default='warning', description="Log level for network-related messages. 'info' and 'debug' are very verbose.")
# Development
use_memory_db: bool = Field(default=False, description="Use in-memory database. Useful for development.")
@@ -170,14 +188,25 @@ class InvokeAIAppConfig(BaseSettings):
profiles_dir: Path = Field(default=Path("profiles"), description="Path to profiles output directory.")
# CACHE
- ram: float = Field(default_factory=get_default_ram_cache_size, gt=0, description="Maximum memory amount used by memory model cache for rapid switching (GB).")
- vram: float = Field(default=DEFAULT_VRAM_CACHE, ge=0, description="Amount of VRAM reserved for model storage (GB).")
- convert_cache: float = Field(default=DEFAULT_CONVERT_CACHE, ge=0, description="Maximum size of on-disk converted models cache (GB).")
- lazy_offload: bool = Field(default=True, description="Keep models in VRAM until their space is needed.")
+ max_cache_ram_gb: Optional[float] = Field(default=None, gt=0, description="The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.")
+ max_cache_vram_gb: Optional[float] = Field(default=None, ge=0, description="The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.")
log_memory_usage: bool = Field(default=False, description="If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.")
+ model_cache_keep_alive_min: float = Field(default=0, ge=0, description="How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.")
+ device_working_mem_gb: float = Field(default=3, description="The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.")
+ enable_partial_loading: bool = Field(default=True, description="Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.")
+ keep_ram_copy_of_weights: bool = Field(default=True, description="Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.")
+ # Deprecated CACHE configs
+ ram: Optional[float] = Field(default=None, gt=0, description="DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.")
+ vram: Optional[float] = Field(default=None, ge=0, description="DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.")
+ lazy_offload: bool = Field(default=True, description="DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.")
+
+ # PyTorch Memory Allocator
+ pytorch_cuda_alloc_conf: Optional[str] = Field(default=None, description="Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.")
# DEVICE
- device: DEVICE = Field(default="auto", description="Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.")
+ device: str = Field(default="auto", description="Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities. Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)", pattern=r"^(auto|cpu|mps|cuda(:\d+)?)$")
+ generation_devices: Union[Literal["auto"], list[str]] = Field(default="auto", description="Devices to use for parallel generation. `auto` (the default) uses every available GPU, running one generation session per GPU concurrently and distributing jobs fairly across users. Provide an explicit list (e.g. `[cuda:0, cuda:1]`) to use specific devices, or a single-device list (e.g. `[cuda:0]`) to run serially. On systems without a GPU, `auto` resolves to the single `cpu`/`mps` device. Valid values: `auto`, or a list whose entries are each `cpu`, `cuda`, `mps`, or `cuda:N` (where N is a device number)")
+ offload_text_encoders_to_idle_gpus: bool = Field(default=True, description="When running on multiple GPUs, load text encoders onto a currently-idle GPU instead of the one running the denoise pipeline. This avoids churning the denoise model in and out of VRAM to make room for the encoder, and lets a cached encoder be reused across generations. Has no effect unless at least two `generation_devices` are configured and a GPU is idle; under full load encoders run on the session's own GPU as before.")
precision: PRECISION = Field(default="auto", description="Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.")
# GENERATION
@@ -187,7 +216,8 @@ class InvokeAIAppConfig(BaseSettings):
force_tiled_decode: bool = Field(default=False, description="Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).")
pil_compress_level: int = Field(default=1, description="The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.")
max_queue_size: int = Field(default=10000, gt=0, description="Maximum number of items in the session queue.")
- clear_queue_on_startup: bool = Field(default=False, description="Empties session queue on startup.")
+ clear_queue_on_startup: bool = Field(default=False, description="Empties session queue on startup. If true, disables `max_queue_history`.")
+ max_queue_history: Optional[int] = Field(default=None, ge=0, description="Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.")
# NODES
allow_nodes: Optional[list[str]] = Field(default=None, description="List of nodes to allow. Omit to allow all.")
@@ -198,11 +228,58 @@ class InvokeAIAppConfig(BaseSettings):
hashing_algorithm: HASHING_ALGORITHMS = Field(default="blake3_single", description="Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.")
remote_api_tokens: Optional[list[URLRegexTokenPair]] = Field(default=None, description="List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.")
scan_models_on_startup: bool = Field(default=False, description="Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.")
+ unsafe_disable_picklescan: bool = Field(default=False, description="UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.")
+ allow_unknown_models: bool = Field(default=True, description="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
+ 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_alibabacloud_api_key: Optional[str] = Field(default=None, description="API key for Alibaba Cloud DashScope image generation.")
+ external_alibabacloud_base_url: Optional[str] = Field(
+ default=None, description="Base URL override for Alibaba Cloud DashScope image generation."
+ )
+ 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."
+ )
+ external_seedream_api_key: Optional[str] = Field(
+ default=None, description="API key for Seedream image generation."
+ )
+ external_seedream_base_url: Optional[str] = Field(
+ default=None, description="Base URL override for Seedream image generation."
+ )
# fmt: on
model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True)
+ @field_validator("generation_devices")
+ @classmethod
+ def validate_generation_devices(cls, v: Union[str, list[str]]) -> Union[str, list[str]]:
+ if v == "auto":
+ return v
+ # A non-"auto" string would otherwise be iterated character-by-character below (rejecting
+ # 'c' from "cuda:0"), producing a confusing error. Require an explicit list instead.
+ if isinstance(v, str):
+ raise ValueError(
+ f"Invalid generation_devices value '{v}'. Use 'auto' or a list of devices, e.g. ['cuda:0', 'cuda:1']."
+ )
+ if len(v) == 0:
+ raise ValueError("generation_devices cannot be an empty list. Use 'auto' or a list of devices.")
+ pattern = re.compile(r"^(cpu|mps|cuda(:\d+)?)$")
+ for device in v:
+ if not pattern.match(device):
+ raise ValueError(
+ f"Invalid generation device '{device}'. Valid values are 'auto', 'cpu', 'mps', 'cuda', or 'cuda:N'."
+ )
+ return v
+
def update_config(self, config: dict[str, Any] | InvokeAIAppConfig, clobber: bool = True) -> None:
"""Updates the config, overwriting existing values.
@@ -250,13 +327,13 @@ def write_file(self, dest_path: Path, as_example: bool = False) -> None:
)
if as_example:
- file.write(
- "# This is an example file with default and example settings. Use the values here as a baseline.\n\n"
- )
+ file.write("# This is an example file with default and example settings.\n")
+ file.write("# You should not copy this whole file into your config.\n")
+ file.write("# Only add the settings you need to change to your config file.\n\n")
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/features/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))
@@ -280,6 +357,13 @@ def config_file_path(self) -> Path:
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.."""
@@ -302,6 +386,16 @@ def models_path(self) -> Path:
"""Path to the models directory, resolved to an absolute path.."""
return self._resolve(self.models_dir)
+ @property
+ def style_presets_path(self) -> Path:
+ """Path to the style presets directory, resolved to an absolute path.."""
+ return self._resolve(self.style_presets_dir)
+
+ @property
+ def workflow_thumbnails_path(self) -> Path:
+ """Path to the workflow thumbnails directory, resolved to an absolute path.."""
+ return self._resolve(self.workflow_thumbnails_dir)
+
@property
def convert_cache_path(self) -> Path:
"""Path to the converted cache models directory, resolved to an absolute path.."""
@@ -357,14 +451,14 @@ def settings_customise_sources(
return (init_settings,)
-def migrate_v3_config_dict(config_dict: dict[str, Any]) -> InvokeAIAppConfig:
- """Migrate a v3 config dictionary to a current config object.
+def migrate_v3_config_dict(config_dict: dict[str, Any]) -> dict[str, Any]:
+ """Migrate a v3 config dictionary to a v4.0.0.
Args:
config_dict: A dictionary of settings from a v3 config file.
Returns:
- An instance of `InvokeAIAppConfig` with the migrated settings.
+ An `InvokeAIAppConfig` config dict.
"""
parsed_config_dict: dict[str, Any] = {}
@@ -398,32 +492,55 @@ def migrate_v3_config_dict(config_dict: dict[str, Any]) -> InvokeAIAppConfig:
elif k in InvokeAIAppConfig.model_fields:
# skip unknown fields
parsed_config_dict[k] = v
- # When migrating the config file, we should not include currently-set environment variables.
- config = DefaultInvokeAIAppConfig.model_validate(parsed_config_dict)
-
- return config
+ parsed_config_dict["schema_version"] = "4.0.0"
+ return parsed_config_dict
-def migrate_v4_0_0_config_dict(config_dict: dict[str, Any]) -> InvokeAIAppConfig:
- """Migrate v4.0.0 config dictionary to a current config object.
+def migrate_v4_0_0_to_4_0_1_config_dict(config_dict: dict[str, Any]) -> dict[str, Any]:
+ """Migrate v4.0.0 config dictionary to a v4.0.1 config dictionary
Args:
config_dict: A dictionary of settings from a v4.0.0 config file.
Returns:
- An instance of `InvokeAIAppConfig` with the migrated settings.
+ A config dict with the settings migrated to v4.0.1.
"""
- parsed_config_dict: dict[str, Any] = {}
- for k, v in config_dict.items():
- # autocast was removed from precision in v4.0.1
- if k == "precision" and v == "autocast":
- parsed_config_dict["precision"] = "auto"
- else:
- parsed_config_dict[k] = v
- if k == "schema_version":
- parsed_config_dict[k] = CONFIG_SCHEMA_VERSION
- config = DefaultInvokeAIAppConfig.model_validate(parsed_config_dict)
- return config
+ parsed_config_dict: dict[str, Any] = copy.deepcopy(config_dict)
+ # precision "autocast" was replaced by "auto" in v4.0.1
+ if parsed_config_dict.get("precision") == "autocast":
+ parsed_config_dict["precision"] = "auto"
+ parsed_config_dict["schema_version"] = "4.0.1"
+ return parsed_config_dict
+
+
+def migrate_v4_0_1_to_4_0_2_config_dict(config_dict: dict[str, Any]) -> dict[str, Any]:
+ """Migrate v4.0.1 config dictionary to a v4.0.2 config dictionary.
+
+ Args:
+ config_dict: A dictionary of settings from a v4.0.1 config file.
+
+ Returns:
+ An config dict with the settings migrated to v4.0.2.
+ """
+ parsed_config_dict: dict[str, Any] = copy.deepcopy(config_dict)
+ # convert_cache was removed in 4.0.2
+ parsed_config_dict.pop("convert_cache", None)
+ parsed_config_dict["schema_version"] = "4.0.2"
+ return parsed_config_dict
+
+
+def migrate_v4_0_2_to_4_0_3_config_dict(config_dict: dict[str, Any]) -> dict[str, Any]:
+ """Migrate v4.0.2 config dictionary to a v4.0.3 config dictionary.
+
+ Args:
+ config_dict: A dictionary of settings from a v4.0.2 config file.
+
+ Returns:
+ A config dict with the settings migrated to v4.0.3.
+ """
+ parsed_config_dict: dict[str, Any] = copy.deepcopy(config_dict)
+ parsed_config_dict["schema_version"] = "4.0.3"
+ return parsed_config_dict
def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig:
@@ -437,38 +554,75 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig:
"""
assert config_path.suffix == ".yaml"
with open(config_path, "rt", encoding=locale.getpreferredencoding()) as file:
- loaded_config_dict = yaml.safe_load(file)
+ loaded_config_dict: dict[str, Any] = yaml.safe_load(file)
assert isinstance(loaded_config_dict, dict)
+ migrated = False
if "InvokeAI" in loaded_config_dict:
- # This is a v3 config file, attempt to migrate it
+ migrated = True
+ loaded_config_dict = migrate_v3_config_dict(loaded_config_dict) # pyright: ignore [reportUnknownArgumentType]
+ if loaded_config_dict["schema_version"] == "4.0.0":
+ migrated = True
+ loaded_config_dict = migrate_v4_0_0_to_4_0_1_config_dict(loaded_config_dict)
+ if loaded_config_dict["schema_version"] == "4.0.1":
+ migrated = True
+ loaded_config_dict = migrate_v4_0_1_to_4_0_2_config_dict(loaded_config_dict)
+ if loaded_config_dict["schema_version"] == "4.0.2":
+ migrated = True
+ loaded_config_dict = migrate_v4_0_2_to_4_0_3_config_dict(loaded_config_dict)
+
+ if migrated:
shutil.copy(config_path, config_path.with_suffix(".yaml.bak"))
try:
- # loaded_config_dict could be the wrong shape, but we will catch all exceptions below
- migrated_config = migrate_v3_config_dict(loaded_config_dict) # pyright: ignore [reportUnknownArgumentType]
+ # load and write without environment variables
+ migrated_config = DefaultInvokeAIAppConfig.model_validate(loaded_config_dict)
+ migrated_config.write_file(config_path)
except Exception as e:
shutil.copy(config_path.with_suffix(".yaml.bak"), config_path)
raise RuntimeError(f"Failed to load and migrate v3 config file {config_path}: {e}") from e
- migrated_config.write_file(config_path)
- return migrated_config
- if loaded_config_dict["schema_version"] == "4.0.0":
- loaded_config_dict = migrate_v4_0_0_config_dict(loaded_config_dict)
- loaded_config_dict.write_file(config_path)
-
- # Attempt to load as a v4 config file
try:
# Meta is not included in the model fields, so we need to validate it separately
config = InvokeAIAppConfig.model_validate(loaded_config_dict)
- assert (
- config.schema_version == CONFIG_SCHEMA_VERSION
- ), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}"
+ assert config.schema_version == CONFIG_SCHEMA_VERSION, (
+ f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}"
+ )
return config
except Exception as e:
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.
@@ -485,6 +639,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
@@ -507,9 +662,35 @@ def get_config() -> InvokeAIAppConfig:
]
example_config.write_file(config.config_file_path.with_suffix(".example.yaml"), as_example=True)
- # Copy all legacy configs - We know `__path__[0]` is correct here
+ # Copy all legacy configs only if needed
+ # We know `__path__[0]` is correct here
configs_src = Path(model_configs.__path__[0]) # pyright: ignore [reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue]
- shutil.copytree(configs_src, config.legacy_conf_path, dirs_exist_ok=True)
+ dest_path = config.legacy_conf_path
+
+ # Create destination (we don't need to check for existence)
+ dest_path.mkdir(parents=True, exist_ok=True)
+
+ # Compare directories recursively
+ comparison = filecmp.dircmp(configs_src, dest_path)
+ need_copy = any(
+ [
+ comparison.left_only, # Files exist only in source
+ comparison.diff_files, # Files that differ
+ comparison.common_funny, # Files that couldn't be compared
+ ]
+ )
+
+ if need_copy:
+ # Get permissions from destination directory
+ dest_mode = dest_path.stat().st_mode
+
+ # Copy directory tree
+ shutil.copytree(configs_src, dest_path, dirs_exist_ok=True)
+
+ # Set permissions on copied files to match destination directory
+ dest_path.chmod(dest_mode)
+ for p in dest_path.glob("**/*"):
+ p.chmod(dest_mode)
if config.config_file_path.exists():
config_from_file = load_and_migrate_config(config.config_file_path)
@@ -520,4 +701,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
diff --git a/invokeai/app/services/download/__init__.py b/invokeai/app/services/download/__init__.py
index 33b0025809c..48ded7d5496 100644
--- a/invokeai/app/services/download/__init__.py
+++ b/invokeai/app/services/download/__init__.py
@@ -1,13 +1,13 @@
"""Init file for download queue."""
-from .download_base import (
+from invokeai.app.services.download.download_base import (
DownloadJob,
DownloadJobStatus,
DownloadQueueServiceBase,
MultiFileDownloadJob,
UnknownJobIDException,
)
-from .download_default import DownloadQueueService, TqdmProgress
+from invokeai.app.services.download.download_default import DownloadQueueService, TqdmProgress
__all__ = [
"DownloadJob",
diff --git a/invokeai/app/services/download/download_base.py b/invokeai/app/services/download/download_base.py
index 4880ab98b89..1798fd69df5 100644
--- a/invokeai/app/services/download/download_base.py
+++ b/invokeai/app/services/download/download_base.py
@@ -18,6 +18,7 @@ class DownloadJobStatus(str, Enum):
WAITING = "waiting" # not enqueued, will not run
RUNNING = "running" # actively downloading
+ PAUSED = "paused" # paused, can be resumed
COMPLETED = "completed" # finished running
CANCELLED = "cancelled" # user cancelled
ERROR = "error" # terminated with an error message
@@ -61,6 +62,7 @@ class DownloadJobBase(BaseModel):
# internal flag
_cancelled: bool = PrivateAttr(default=False)
+ _paused: bool = PrivateAttr(default=False)
# optional event handlers passed in on creation
_on_start: Optional[DownloadEventHandler] = PrivateAttr(default=None)
@@ -72,6 +74,12 @@ class DownloadJobBase(BaseModel):
def cancel(self) -> None:
"""Call to cancel the job."""
self._cancelled = True
+ self._paused = False
+
+ def pause(self) -> None:
+ """Pause the job, preserving partial downloads."""
+ self._paused = True
+ self._cancelled = True
# cancelled and the callbacks are private attributes in order to prevent
# them from being serialized and/or used in the Json Schema
@@ -80,6 +88,11 @@ def cancelled(self) -> bool:
"""Call to cancel the job."""
return self._cancelled
+ @property
+ def paused(self) -> bool:
+ """Return true if job is paused."""
+ return self._paused
+
@property
def complete(self) -> bool:
"""Return true if job completed without errors."""
@@ -161,6 +174,17 @@ class DownloadJob(DownloadJobBase):
default=None, description="Timestamp for when the download job ende1d (completed or errored)"
)
content_type: Optional[str] = Field(default=None, description="Content type of downloaded file")
+ canonical_url: Optional[str] = Field(default=None, description="Canonical URL to request on resume")
+ etag: Optional[str] = Field(default=None, description="ETag from the remote server, if available")
+ last_modified: Optional[str] = Field(default=None, description="Last-Modified from the remote server, if available")
+ final_url: Optional[str] = Field(default=None, description="Final resolved URL after redirects, if available")
+ expected_total_bytes: Optional[int] = Field(default=None, description="Expected total size of the download")
+ resume_required: bool = Field(default=False, description="True if server refused resume; restart required")
+ resume_message: Optional[str] = Field(default=None, description="Message explaining why resume is required")
+ resume_from_scratch: bool = Field(
+ default=False,
+ description="True if resume metadata existed but the partial file was missing and the download restarted from the beginning",
+ )
def __hash__(self) -> int:
"""Return hash of the string representation of this object, for indexing."""
@@ -321,6 +345,10 @@ def cancel_job(self, job: DownloadJobBase) -> None:
"""Cancel the job, clearing partial downloads and putting it into ERROR state."""
pass
+ def pause_job(self, job: DownloadJobBase) -> None: # noqa D401
+ """Pause the job, preserving partial downloads."""
+ raise NotImplementedError
+
@abstractmethod
def join(self) -> None:
"""Wait until all jobs are off the queue."""
diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py
index f6c7c1a1a0c..13e86d18284 100644
--- a/invokeai/app/services/download/download_default.py
+++ b/invokeai/app/services/download/download_default.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2023, Lincoln D. Stein
+# Copyright (c) 2023,2026 Lincoln D. Stein
"""Implementation of multithreaded download queue for invokeai."""
import os
@@ -8,7 +8,9 @@
import traceback
from pathlib import Path
from queue import Empty, PriorityQueue
-from typing import Any, Dict, List, Literal, Optional, Set
+from shutil import disk_usage
+from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Set
+from urllib.parse import urlparse
import requests
from pydantic.networks import AnyHttpUrl
@@ -16,12 +18,7 @@
from tqdm import tqdm
from invokeai.app.services.config import InvokeAIAppConfig, get_config
-from invokeai.app.services.events.events_base import EventServiceBase
-from invokeai.app.util.misc import get_iso_timestamp
-from invokeai.backend.model_manager.metadata import RemoteModelFile
-from invokeai.backend.util.logging import InvokeAILogger
-
-from .download_base import (
+from invokeai.app.services.download.download_base import (
DownloadEventHandler,
DownloadExceptionHandler,
DownloadJob,
@@ -33,6 +30,12 @@
ServiceInactiveException,
UnknownJobIDException,
)
+from invokeai.app.util.misc import get_iso_timestamp
+from invokeai.backend.model_manager.metadata import RemoteModelFile
+from invokeai.backend.util.logging import InvokeAILogger
+
+if TYPE_CHECKING:
+ from invokeai.app.services.events.events_base import EventServiceBase
# Maximum number of bytes to download during each call to requests.iter_content()
DOWNLOAD_CHUNK_SIZE = 100000
@@ -57,7 +60,9 @@ def __init__(
"""
self._app_config = app_config or get_config()
self._jobs: Dict[int, DownloadJob] = {}
- self._download_part2parent: Dict[AnyHttpUrl, MultiFileDownloadJob] = {}
+ self._download_part2parent: Dict[int, MultiFileDownloadJob] = {}
+ self._mfd_pending: Dict[int, list[DownloadJob]] = {}
+ self._mfd_active: Dict[int, DownloadJob] = {}
self._next_job_id = 0
self._queue: PriorityQueue[DownloadJob] = PriorityQueue()
self._stop_event = threading.Event()
@@ -83,7 +88,7 @@ def stop(self, *args: Any, **kwargs: Any) -> None:
"""Stop the download worker threads."""
with self._lock:
if not self._worker_pool:
- raise Exception("Attempt to stop the download service before it was started")
+ return
self._accept_download_requests = False # reject attempts to add new jobs to queue
queued_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.WAITING]
active_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.RUNNING]
@@ -113,7 +118,8 @@ def submit_download_job(
raise ServiceInactiveException(
"The download service is not currently accepting requests. Please call start() to initialize the service."
)
- job.id = self._next_id()
+ if job.id == -1:
+ job.id = self._next_id()
job.set_callbacks(
on_start=on_start,
on_progress=on_progress,
@@ -124,6 +130,11 @@ def submit_download_job(
self._jobs[job.id] = job
self._queue.put(job)
+ def pause_job(self, job: DownloadJobBase) -> None:
+ """Pause the indicated job, preserving partial downloads."""
+ if job.status in [DownloadJobStatus.WAITING, DownloadJobStatus.RUNNING]:
+ job.pause()
+
def download(
self,
source: AnyHttpUrl,
@@ -185,24 +196,41 @@ def multifile_download(
job = DownloadJob(
source=url,
dest=path,
- access_token=access_token,
+ access_token=access_token or self._lookup_access_token(url),
)
+ job.id = self._next_id() # pre-assign ID so _download_part2parent can be keyed by ID
+ if part.size and part.size > 0:
+ job.total_bytes = part.size
+ job.expected_total_bytes = part.size
+ job.canonical_url = str(url)
mfdj.download_parts.add(job)
- self._download_part2parent[job.source] = mfdj
+ self._download_part2parent[job.id] = mfdj
if submit_job:
self.submit_multifile_download(mfdj)
return mfdj
def submit_multifile_download(self, job: MultiFileDownloadJob) -> None:
- for download_job in job.download_parts:
- self.submit_download_job(
- download_job,
- on_start=self._mfd_started,
- on_progress=self._mfd_progress,
- on_complete=self._mfd_complete,
- on_cancelled=self._mfd_cancelled,
- on_error=self._mfd_error,
- )
+ pending = sorted(job.download_parts, key=lambda j: str(j.source))
+ self._mfd_pending[job.id] = list(pending)
+ self._mfd_active.pop(job.id, None)
+ self._submit_next_mfd_part(job)
+
+ def _submit_next_mfd_part(self, job: MultiFileDownloadJob) -> None:
+ pending = self._mfd_pending.get(job.id, [])
+ if not pending:
+ return
+ if self._mfd_active.get(job.id) is not None:
+ return
+ download_job = pending.pop(0)
+ self._mfd_active[job.id] = download_job
+ self.submit_download_job(
+ download_job,
+ on_start=self._mfd_started,
+ on_progress=self._mfd_progress,
+ on_complete=self._mfd_complete,
+ on_cancelled=self._mfd_cancelled,
+ on_error=self._mfd_error,
+ )
def join(self) -> None:
"""Wait for all jobs to complete."""
@@ -282,12 +310,18 @@ def _download_next_item(self) -> None:
except Empty:
continue
try:
+ if job.cancelled:
+ raise DownloadJobCancelledException("Job was cancelled before start")
job.job_started = get_iso_timestamp()
self._do_download(job)
- self._signal_job_complete(job)
+ if job.status != DownloadJobStatus.COMPLETED:
+ self._signal_job_complete(job)
except DownloadJobCancelledException:
- self._signal_job_cancelled(job)
- self._cleanup_cancelled_job(job)
+ if job.paused:
+ self._signal_job_paused(job)
+ else:
+ self._signal_job_cancelled(job)
+ self._cleanup_cancelled_job(job)
except Exception as excp:
job.error_type = excp.__class__.__name__ + f"({str(excp)})"
job.error = traceback.format_exc()
@@ -295,8 +329,7 @@ def _download_next_item(self) -> None:
finally:
job.job_ended = get_iso_timestamp()
self._job_terminated_event.set() # signal a change to terminal state
- self._download_part2parent.pop(job.source, None) # if this is a subpart of a multipart job, remove it
- self._job_terminated_event.set()
+ self._download_part2parent.pop(job.id, None) # if this is a subpart of a multipart job, remove it
self._queue.task_done()
self._logger.debug(f"Download queue worker thread {threading.current_thread().name} exiting.")
@@ -304,22 +337,130 @@ def _download_next_item(self) -> None:
def _do_download(self, job: DownloadJob) -> None:
"""Do the actual download."""
- url = job.source
+ url = job.canonical_url or str(job.source)
header = {"Authorization": f"Bearer {job.access_token}"} if job.access_token else {}
+ had_resume_metadata = bool(job.etag or job.last_modified)
open_mode = "wb"
+ resume_from = 0
+
+ if not job.dest.is_dir():
+ job.download_path = job.dest
+ in_progress_path = self._in_progress_path(job.download_path)
+ if in_progress_path.exists():
+ resume_from = in_progress_path.stat().st_size
+ job.bytes = resume_from
+ self._logger.debug(
+ f"Resume check: in-progress file found at {in_progress_path} size={resume_from} bytes"
+ )
+ if resume_from > 0:
+ if job.etag:
+ header["If-Range"] = job.etag
+ elif job.last_modified:
+ header["If-Range"] = job.last_modified
+ header["Range"] = f"bytes={resume_from}-"
+ open_mode = "ab"
+ else:
+ self._logger.debug(f"Resume check: no in-progress file at {in_progress_path}")
+ elif job.download_path:
+ # Resume for directory downloads when we already know the filename.
+ in_progress_path = self._in_progress_path(job.download_path)
+ if in_progress_path.exists():
+ resume_from = in_progress_path.stat().st_size
+ job.bytes = resume_from
+ self._logger.debug(
+ f"Resume check (dir): in-progress file found at {in_progress_path} size={resume_from} bytes"
+ )
+ if resume_from > 0:
+ if job.etag:
+ header["If-Range"] = job.etag
+ elif job.last_modified:
+ header["If-Range"] = job.last_modified
+ header["Range"] = f"bytes={resume_from}-"
+ open_mode = "ab"
+ else:
+ self._logger.debug(f"Resume check (dir): no in-progress file at {in_progress_path}")
+ elif job.dest.is_dir():
+ # Attempt to infer a single in-progress file from disk for directory downloads.
+ try:
+ candidates = sorted(job.dest.glob("*.downloading"))
+ except OSError:
+ candidates = []
+ if len(candidates) == 1:
+ inferred = candidates[0].with_name(candidates[0].name.removesuffix(".downloading"))
+ job.download_path = inferred
+ try:
+ resume_from = candidates[0].stat().st_size
+ except FileNotFoundError:
+ # The .downloading file was renamed/deleted between glob and stat (race condition); skip resume.
+ job.download_path = None
+ else:
+ job.bytes = resume_from
+ self._logger.debug(
+ f"Resume check (dir): inferred in-progress file path={candidates[0]} size={resume_from} bytes"
+ )
+ if resume_from > 0:
+ if job.etag:
+ header["If-Range"] = job.etag
+ elif job.last_modified:
+ header["If-Range"] = job.last_modified
+ header["Range"] = f"bytes={resume_from}-"
+ open_mode = "ab"
+ else:
+ self._logger.debug(
+ "Resume check (dir): no prior download_path available; cannot resume from disk "
+ f"(candidates={len(candidates)})"
+ )
+
+ if resume_from == 0:
+ job.bytes = 0
+ if had_resume_metadata:
+ job.resume_from_scratch = True
+ job.resume_message = "Partial file missing. Restarted download from the beginning."
# Make a streaming request. This will retrieve headers including
# content-length and content-disposition, but not fetch any content itself
resp = self._requests.get(str(url), headers=header, stream=True)
+ job.final_url = str(resp.url) if resp.url else None
+ self._logger.debug(
+ "Resume response: "
+ f"status={resp.status_code} "
+ f"content_length={resp.headers.get('content-length')} "
+ f"content_range={resp.headers.get('Content-Range')} "
+ f"etag={resp.headers.get('ETag')} "
+ f"last_modified={resp.headers.get('Last-Modified')}"
+ )
+ if resp.status_code == 416 and resume_from > 0:
+ # Range not satisfiable - local partial is already complete
+ expected = job.expected_total_bytes or job.total_bytes or resume_from
+ if resume_from == expected:
+ job.total_bytes = expected
+ job.bytes = resume_from
+ job.download_path = job.download_path or job.dest
+ self._signal_job_started(job)
+ self._signal_job_complete(job)
+ return
+ job.resume_required = True
+ job.resume_message = "Resume refused by server. Restart required."
+ job.pause()
+ raise DownloadJobCancelledException("Resume refused by server. Restart required.")
if not resp.ok:
- raise HTTPError(resp.reason)
+ host = urlparse(str(resp.url or url)).netloc
+ status = resp.status_code
+ reason = resp.reason
+ if status >= 500:
+ self._logger.error(f"Remote server error from {host}: HTTP {status} {reason}")
+ raise HTTPError(reason)
+ self._logger.error(f"Download failed from {host}: HTTP {status} {reason}")
+ raise HTTPError(reason)
job.content_type = resp.headers.get("Content-Type")
+ job.etag = resp.headers.get("ETag") or job.etag
+ job.last_modified = resp.headers.get("Last-Modified") or job.last_modified
content_length = int(resp.headers.get("content-length", 0))
- job.total_bytes = content_length
if job.dest.is_dir():
- file_name = os.path.basename(str(url.path)) # default is to use the last bit of the URL
+ parsed_url = urlparse(str(url))
+ file_name = os.path.basename(parsed_url.path) # default is to use the last bit of the URL
if match := re.search('filename="(.+)"', resp.headers.get("Content-Disposition", "")):
remote_name = match.group(1)
@@ -334,31 +475,83 @@ def _do_download(self, job: DownloadJob) -> None:
assert job.download_path
+ in_progress_path = self._in_progress_path(job.download_path)
+
+ if resume_from > 0 and resp.status_code == 200:
+ # Server ignored Range. Restart download from scratch.
+ job.resume_required = True
+ job.resume_message = "Resume refused by server. Restart required."
+ job.pause()
+ raise DownloadJobCancelledException("Resume refused by server. Restart required.")
+
+ if resume_from > 0 and resp.status_code == 206:
+ content_range = resp.headers.get("Content-Range", "")
+ total_from_range = None
+ if match := re.match(r"bytes\s+\d+-\d+/(\d+)", content_range):
+ total_from_range = int(match.group(1))
+ if total_from_range is not None:
+ job.total_bytes = total_from_range
+ else:
+ job.total_bytes = resume_from + content_length
+ job.bytes = resume_from
+ job.expected_total_bytes = job.total_bytes
+ else:
+ job.total_bytes = content_length
+ job.expected_total_bytes = content_length
+
+ if job.download_path.exists() and resume_from == 0:
+ existing_size = job.download_path.stat().st_size
+ if job.total_bytes > 0 and existing_size == job.total_bytes:
+ job.bytes = existing_size
+ self._signal_job_started(job)
+ self._signal_job_complete(job)
+ return
+ # Existing file does not match expected size; treat as corrupt and restart.
+ self._logger.debug(
+ "Resume check: existing file size mismatch; deleting and restarting "
+ f"path={job.download_path} existing_size={existing_size} expected={job.total_bytes}"
+ )
+ job.download_path.unlink()
+
+ free_space = disk_usage(job.download_path.parent).free
+ GB = 2**30
+ remaining_bytes = max(job.total_bytes - job.bytes, 0)
+ if free_space < remaining_bytes:
+ raise RuntimeError(
+ f"Free disk space {free_space / GB:.2f} GB is not enough for download of {remaining_bytes / GB:.2f} GB."
+ )
+
# Don't clobber an existing file. See commit 82c2c85202f88c6d24ff84710f297cfc6ae174af
# for code that instead resumes an interrupted download.
- if job.download_path.exists():
+ if job.download_path.exists() and resume_from == 0:
raise OSError(f"[Errno 17] File {job.download_path} exists")
# append ".downloading" to the path
- in_progress_path = self._in_progress_path(job.download_path)
-
# signal caller that the download is starting. At this point, key fields such as
# download_path and total_bytes will be populated. We call it here because the might
# discover that the local file is already complete and generate a COMPLETED status.
self._signal_job_started(job)
+ expected_total = job.total_bytes or job.expected_total_bytes or content_length
# "range not satisfiable" - local file is at least as large as the remote file
- if resp.status_code == 416 or (content_length > 0 and job.bytes >= content_length):
- self._logger.warning(f"{job.download_path}: complete file found. Skipping.")
+ if resp.status_code == 416 or (expected_total > 0 and job.bytes >= expected_total):
+ self._logger.info(f"{job.download_path}: complete file found. Skipping.")
return
# "partial content" - local file is smaller than remote file
elif resp.status_code == 206 or job.bytes > 0:
- self._logger.warning(f"{job.download_path}: partial file found. Resuming")
+ self._logger.info(f"{job.download_path}: partial file found. Resuming")
# some other error
elif resp.status_code != 200:
- raise HTTPError(resp.reason)
+ host = urlparse(str(resp.url or url)).netloc
+ status = resp.status_code
+ reason = resp.reason
+ if status >= 500:
+ self._logger.error(f"Remote server error from {host}: HTTP {status} {reason}")
+ raise HTTPError(reason)
+ self._logger.error(f"Download failed from {host}: HTTP {status} {reason}")
+ raise HTTPError(reason)
self._logger.debug(f"{job.source}: Downloading {job.download_path}")
report_delta = job.total_bytes / 100 # report every 1% change
@@ -375,6 +568,12 @@ def _do_download(self, job: DownloadJob) -> None:
last_report_bytes = job.bytes
self._signal_job_progress(job)
+ if job.total_bytes > 0 and job.bytes < job.total_bytes:
+ job.resume_required = True
+ job.resume_message = "Download interrupted. Resume required."
+ job.pause()
+ raise DownloadJobCancelledException("Download interrupted. Resume required.")
+
# if we get here we are done and can rename the file to the original dest
self._logger.debug(f"{job.source}: saved to {job.download_path} (bytes={job.bytes})")
in_progress_path.rename(job.download_path)
@@ -430,11 +629,28 @@ def _signal_job_cancelled(self, job: DownloadJob) -> None:
self._event_bus.emit_download_cancelled(job)
# if multifile download, then signal the parent
- if parent_job := self._download_part2parent.get(job.source, None):
+ if parent_job := self._download_part2parent.get(job.id, None):
if not parent_job.in_terminal_state:
parent_job.status = DownloadJobStatus.CANCELLED
self._execute_cb(parent_job, "on_cancelled")
+ def _signal_job_paused(self, job: DownloadJob) -> None:
+ if job.status not in [DownloadJobStatus.RUNNING, DownloadJobStatus.WAITING]:
+ return
+ if job.download_path:
+ in_progress_path = self._in_progress_path(job.download_path)
+ if in_progress_path.exists():
+ job.bytes = in_progress_path.stat().st_size
+ job.status = DownloadJobStatus.PAUSED
+ self._execute_cb(job, "on_cancelled")
+ if self._event_bus:
+ self._event_bus.emit_download_paused(job)
+
+ if parent_job := self._download_part2parent.get(job.id, None):
+ if not parent_job.in_terminal_state:
+ parent_job.status = DownloadJobStatus.PAUSED
+ self._execute_cb(parent_job, "on_cancelled")
+
def _signal_job_error(self, job: DownloadJob, excp: Optional[Exception] = None) -> None:
job.status = DownloadJobStatus.ERROR
self._logger.error(f"{str(job.source)}: {traceback.format_exception(excp)}")
@@ -444,6 +660,8 @@ def _signal_job_error(self, job: DownloadJob, excp: Optional[Exception] = None)
self._event_bus.emit_download_error(job)
def _cleanup_cancelled_job(self, job: DownloadJob) -> None:
+ if job.paused:
+ return
self._logger.debug(f"Cleaning up leftover files from cancelled download job {job.download_path}")
try:
if job.download_path:
@@ -458,7 +676,7 @@ def _cleanup_cancelled_job(self, job: DownloadJob) -> None:
def _mfd_started(self, download_job: DownloadJob) -> None:
self._logger.info(f"File download started: {download_job.source}")
with self._lock:
- mf_job = self._download_part2parent[download_job.source]
+ mf_job = self._download_part2parent[download_job.id]
if mf_job.waiting:
mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts)
mf_job.status = DownloadJobStatus.RUNNING
@@ -471,44 +689,62 @@ def _mfd_started(self, download_job: DownloadJob) -> None:
def _mfd_progress(self, download_job: DownloadJob) -> None:
with self._lock:
- mf_job = self._download_part2parent[download_job.source]
+ mf_job = self._download_part2parent[download_job.id]
if mf_job.cancelled:
for part in mf_job.download_parts:
self.cancel_job(part)
elif mf_job.running:
mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts)
- mf_job.bytes = sum(x.total_bytes for x in mf_job.download_parts)
+ mf_job.bytes = sum(x.bytes for x in mf_job.download_parts)
self._execute_cb(mf_job, "on_progress")
def _mfd_complete(self, download_job: DownloadJob) -> None:
self._logger.info(f"Download complete: {download_job.source}")
+ submit_next = False
+ mf_job: Optional[MultiFileDownloadJob] = None
with self._lock:
- mf_job = self._download_part2parent[download_job.source]
+ mf_job = self._download_part2parent[download_job.id]
+ self._mfd_active.pop(mf_job.id, None)
+ mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts)
+ mf_job.bytes = sum(x.bytes for x in mf_job.download_parts)
# are there any more active jobs left in this task?
- if mf_job.running and all(x.complete for x in mf_job.download_parts):
+ if all(x.complete for x in mf_job.download_parts):
mf_job.status = DownloadJobStatus.COMPLETED
self._execute_cb(mf_job, "on_complete")
+ elif not mf_job.in_terminal_state and not mf_job.paused:
+ submit_next = True
# we're done with this sub-job
self._job_terminated_event.set()
+ if submit_next and mf_job is not None:
+ self._submit_next_mfd_part(mf_job)
def _mfd_cancelled(self, download_job: DownloadJob) -> None:
with self._lock:
- mf_job = self._download_part2parent[download_job.source]
+ mf_job = self._download_part2parent[download_job.id]
assert mf_job is not None
+ self._mfd_active.pop(mf_job.id, None)
if not mf_job.in_terminal_state:
- self._logger.warning(f"Download cancelled: {download_job.source}")
- mf_job.cancel()
-
+ if download_job.paused:
+ self._logger.warning(f"Download paused: {download_job.source}")
+ mf_job.pause()
+ else:
+ self._logger.warning(f"Download cancelled: {download_job.source}")
+ mf_job.cancel()
+
+ if download_job.paused:
+ return
for s in mf_job.download_parts:
self.cancel_job(s)
+ self._mfd_pending.pop(mf_job.id, None)
def _mfd_error(self, download_job: DownloadJob, excp: Optional[Exception] = None) -> None:
with self._lock:
- mf_job = self._download_part2parent[download_job.source]
+ mf_job = self._download_part2parent[download_job.id]
assert mf_job is not None
+ self._mfd_active.pop(mf_job.id, None)
if not mf_job.in_terminal_state:
mf_job.status = download_job.status
mf_job.error = download_job.error
@@ -519,7 +755,7 @@ def _mfd_error(self, download_job: DownloadJob, excp: Optional[Exception] = None
)
for s in [x for x in mf_job.download_parts if x.running]:
self.cancel_job(s)
- self._download_part2parent.pop(download_job.source)
+ self._mfd_pending.pop(mf_job.id, None)
self._job_terminated_event.set()
def _execute_cb(
diff --git a/invokeai/app/services/events/__init__.py b/invokeai/app/services/events/__init__.py
index 17407d3b72a..e69de29bb2d 100644
--- a/invokeai/app/services/events/__init__.py
+++ b/invokeai/app/services/events/__init__.py
@@ -1 +0,0 @@
-from .events_base import EventServiceBase # noqa F401
diff --git a/invokeai/app/services/events/events_base.py b/invokeai/app/services/events/events_base.py
index bb578c23e8c..935b422a732 100644
--- a/invokeai/app/services/events/events_base.py
+++ b/invokeai/app/services/events/events_base.py
@@ -11,12 +11,13 @@
DownloadCancelledEvent,
DownloadCompleteEvent,
DownloadErrorEvent,
+ DownloadPausedEvent,
DownloadProgressEvent,
DownloadStartedEvent,
EventBase,
InvocationCompleteEvent,
- InvocationDenoiseProgressEvent,
InvocationErrorEvent,
+ InvocationProgressEvent,
InvocationStartedEvent,
ModelInstallCancelledEvent,
ModelInstallCompleteEvent,
@@ -28,9 +29,10 @@
ModelLoadCompleteEvent,
ModelLoadStartedEvent,
QueueClearedEvent,
+ QueueItemsRetriedEvent,
QueueItemStatusChangedEvent,
+ RecallParametersUpdatedEvent,
)
-from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
if TYPE_CHECKING:
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput
@@ -40,10 +42,12 @@
from invokeai.app.services.session_queue.session_queue_common import (
BatchStatus,
EnqueueBatchResult,
+ RetryItemsResult,
SessionQueueItem,
SessionQueueStatus,
)
- from invokeai.backend.model_manager.config import AnyModelConfig, SubModelType
+ from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+ from invokeai.backend.model_manager.taxonomy import SubModelType
class EventServiceBase:
@@ -58,15 +62,16 @@ def emit_invocation_started(self, queue_item: "SessionQueueItem", invocation: "B
"""Emitted when an invocation is started"""
self.dispatch(InvocationStartedEvent.build(queue_item, invocation))
- def emit_invocation_denoise_progress(
+ def emit_invocation_progress(
self,
queue_item: "SessionQueueItem",
invocation: "BaseInvocation",
- intermediate_state: PipelineIntermediateState,
- progress_image: "ProgressImage",
+ message: str,
+ percentage: float | None = None,
+ image: "ProgressImage | None" = None,
) -> None:
- """Emitted at each step during denoising of an invocation."""
- self.dispatch(InvocationDenoiseProgressEvent.build(queue_item, invocation, intermediate_state, progress_image))
+ """Emitted at periodically during an invocation"""
+ self.dispatch(InvocationProgressEvent.build(queue_item, invocation, message, percentage, image))
def emit_invocation_complete(
self, queue_item: "SessionQueueItem", invocation: "BaseInvocation", output: "BaseInvocationOutput"
@@ -95,14 +100,22 @@ def emit_queue_item_status_changed(
"""Emitted when a queue item's status changes"""
self.dispatch(QueueItemStatusChangedEvent.build(queue_item, batch_status, queue_status))
- def emit_batch_enqueued(self, enqueue_result: "EnqueueBatchResult") -> None:
+ def emit_batch_enqueued(self, enqueue_result: "EnqueueBatchResult", user_id: str = "system") -> None:
"""Emitted when a batch is enqueued"""
- self.dispatch(BatchEnqueuedEvent.build(enqueue_result))
+ self.dispatch(BatchEnqueuedEvent.build(enqueue_result, user_id))
+
+ def emit_queue_items_retried(self, retry_result: "RetryItemsResult") -> None:
+ """Emitted when a list of queue items are retried"""
+ self.dispatch(QueueItemsRetriedEvent.build(retry_result))
def emit_queue_cleared(self, queue_id: str) -> None:
"""Emitted when a queue is cleared"""
self.dispatch(QueueClearedEvent.build(queue_id))
+ def emit_recall_parameters_updated(self, queue_id: str, user_id: str, parameters: dict) -> None:
+ """Emitted when recall parameters are updated"""
+ self.dispatch(RecallParametersUpdatedEvent.build(queue_id, user_id, parameters))
+
# endregion
# region Download
@@ -123,6 +136,10 @@ def emit_download_cancelled(self, job: "DownloadJob") -> None:
"""Emitted when a download is cancelled"""
self.dispatch(DownloadCancelledEvent.build(job))
+ def emit_download_paused(self, job: "DownloadJob") -> None:
+ """Emitted when a download is paused"""
+ self.dispatch(DownloadPausedEvent.build(job))
+
def emit_download_error(self, job: "DownloadJob") -> None:
"""Emitted when a download encounters an error"""
self.dispatch(DownloadErrorEvent.build(job))
@@ -177,23 +194,42 @@ def emit_model_install_error(self, job: "ModelInstallJob") -> None:
# region Bulk image download
def emit_bulk_download_started(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> None:
"""Emitted when a bulk image download is started"""
- self.dispatch(BulkDownloadStartedEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name))
+ self.dispatch(
+ BulkDownloadStartedEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id)
+ )
def emit_bulk_download_complete(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> None:
"""Emitted when a bulk image download is complete"""
- self.dispatch(BulkDownloadCompleteEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name))
+ self.dispatch(
+ BulkDownloadCompleteEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name, user_id)
+ )
def emit_bulk_download_error(
- self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, error: str
+ self,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ error: str,
+ user_id: str = "system",
) -> None:
"""Emitted when a bulk image download has an error"""
self.dispatch(
- BulkDownloadErrorEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name, error)
+ BulkDownloadErrorEvent.build(
+ bulk_download_id, bulk_download_item_id, bulk_download_item_name, error, user_id
+ )
)
# endregion
diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py
index c6a867fb081..c30fa31b75c 100644
--- a/invokeai/app/services/events/events_common.py
+++ b/invokeai/app/services/events/events_common.py
@@ -1,26 +1,27 @@
-from math import floor
from typing import TYPE_CHECKING, Any, ClassVar, Coroutine, Generic, Optional, Protocol, TypeAlias, TypeVar
from fastapi_events.handlers.local import local_handler
from fastapi_events.registry.payload_schema import registry as payload_schema
from pydantic import BaseModel, ConfigDict, Field
+from invokeai.app.services.model_install.model_install_common import ModelInstallJob, ModelSource
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
from invokeai.app.services.session_queue.session_queue_common import (
QUEUE_ITEM_STATUS,
BatchStatus,
EnqueueBatchResult,
+ RetryItemsResult,
SessionQueueItem,
SessionQueueStatus,
)
from invokeai.app.services.shared.graph import AnyInvocation, AnyInvocationOutput
from invokeai.app.util.misc import get_timestamp
-from invokeai.backend.model_manager.config import AnyModelConfig, SubModelType
-from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+from invokeai.backend.model_manager.taxonomy import SubModelType
if TYPE_CHECKING:
from invokeai.app.services.download.download_base import DownloadJob
- from invokeai.app.services.model_install.model_install_common import ModelInstallJob
+ from invokeai.app.services.model_install.model_install_common import ModelInstallJob, ModelSource
class EventBase(BaseModel):
@@ -88,6 +89,9 @@ class QueueItemEventBase(QueueEventBase):
item_id: int = Field(description="The ID of the queue item")
batch_id: str = Field(description="The ID of the queue batch")
+ origin: str | None = Field(default=None, description="The origin of the queue item")
+ destination: str | None = Field(default=None, description="The destination of the queue item")
+ user_id: str = Field(default="system", description="The ID of the user who created the queue item")
class InvocationEventBase(QueueItemEventBase):
@@ -95,8 +99,6 @@ class InvocationEventBase(QueueItemEventBase):
session_id: str = Field(description="The ID of the session (aka graph execution state)")
queue_id: str = Field(description="The ID of the queue")
- item_id: int = Field(description="The ID of the queue item")
- batch_id: str = Field(description="The ID of the queue batch")
session_id: str = Field(description="The ID of the session (aka graph execution state)")
invocation: AnyInvocation = Field(description="The ID of the invocation")
invocation_source_id: str = Field(description="The ID of the prepared invocation's source node")
@@ -114,6 +116,9 @@ def build(cls, queue_item: SessionQueueItem, invocation: AnyInvocation) -> "Invo
queue_id=queue_item.queue_id,
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
+ origin=queue_item.origin,
+ destination=queue_item.destination,
+ user_id=queue_item.user_id,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -121,52 +126,55 @@ def build(cls, queue_item: SessionQueueItem, invocation: AnyInvocation) -> "Invo
@payload_schema.register
-class InvocationDenoiseProgressEvent(InvocationEventBase):
- """Event model for invocation_denoise_progress"""
+class InvocationProgressEvent(InvocationEventBase):
+ """Event model for invocation_progress"""
- __event_name__ = "invocation_denoise_progress"
+ __event_name__ = "invocation_progress"
- progress_image: ProgressImage = Field(description="The progress image sent at each step during processing")
- step: int = Field(description="The current step of the invocation")
- total_steps: int = Field(description="The total number of steps in the invocation")
- order: int = Field(description="The order of the invocation in the session")
- percentage: float = Field(description="The percentage of completion of the invocation")
+ message: str = Field(description="A message to display")
+ percentage: float | None = Field(
+ default=None, ge=0, le=1, description="The percentage of the progress (omit to indicate indeterminate progress)"
+ )
+ image: ProgressImage | None = Field(
+ default=None, description="An image representing the current state of the progress"
+ )
+ device: str | None = Field(
+ default=None,
+ description="The device processing this session, e.g. 'cuda:1' (set only when running on a CUDA GPU)",
+ )
@classmethod
def build(
cls,
queue_item: SessionQueueItem,
invocation: AnyInvocation,
- intermediate_state: PipelineIntermediateState,
- progress_image: ProgressImage,
- ) -> "InvocationDenoiseProgressEvent":
- step = intermediate_state.step
- total_steps = intermediate_state.total_steps
- order = intermediate_state.order
+ message: str,
+ percentage: float | None = None,
+ image: ProgressImage | None = None,
+ ) -> "InvocationProgressEvent":
+ # This is emitted from the session-processor worker thread, which pins its CUDA device via
+ # TorchDevice.set_session_device(). Resolve that here so the UI can label progress by GPU.
+ from invokeai.backend.util.devices import TorchDevice
+
+ session_device = TorchDevice.get_session_device()
+ device = str(session_device) if session_device is not None and session_device.type == "cuda" else None
+
return cls(
queue_id=queue_item.queue_id,
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
+ origin=queue_item.origin,
+ destination=queue_item.destination,
+ user_id=queue_item.user_id,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
- progress_image=progress_image,
- step=step,
- total_steps=total_steps,
- order=order,
- percentage=cls.calc_percentage(step, total_steps, order),
+ percentage=percentage,
+ image=image,
+ message=message,
+ device=device,
)
- @staticmethod
- def calc_percentage(step: int, total_steps: int, scheduler_order: float) -> float:
- """Calculate the percentage of completion of denoising."""
- if total_steps == 0:
- return 0.0
- if scheduler_order == 2:
- return floor((step + 1 + 1) / 2) / floor((total_steps + 1) / 2)
- # order == 1
- return (step + 1 + 1) / (total_steps + 1)
-
@payload_schema.register
class InvocationCompleteEvent(InvocationEventBase):
@@ -184,6 +192,9 @@ def build(
queue_id=queue_item.queue_id,
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
+ origin=queue_item.origin,
+ destination=queue_item.destination,
+ user_id=queue_item.user_id,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
@@ -200,8 +211,6 @@ class InvocationErrorEvent(InvocationEventBase):
error_type: str = Field(description="The error type")
error_message: str = Field(description="The error message")
error_traceback: str = Field(description="The error traceback")
- user_id: Optional[str] = Field(default=None, description="The ID of the user who created the invocation")
- project_id: Optional[str] = Field(default=None, description="The ID of the user who created the invocation")
@classmethod
def build(
@@ -216,14 +225,15 @@ def build(
queue_id=queue_item.queue_id,
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
+ origin=queue_item.origin,
+ destination=queue_item.destination,
+ user_id=queue_item.user_id,
session_id=queue_item.session_id,
invocation=invocation,
invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id],
error_type=error_type,
error_message=error_message,
error_traceback=error_traceback,
- user_id=getattr(queue_item, "user_id", None),
- project_id=getattr(queue_item, "project_id", None),
)
@@ -234,11 +244,15 @@ class QueueItemStatusChangedEvent(QueueItemEventBase):
__event_name__ = "queue_item_status_changed"
status: QUEUE_ITEM_STATUS = Field(description="The new status of the queue item")
+ status_sequence: int | None = Field(
+ default=None,
+ description="A monotonically increasing version for this queue item's visible status lifecycle",
+ )
error_type: Optional[str] = Field(default=None, description="The error type, if any")
error_message: Optional[str] = Field(default=None, description="The error message, if any")
error_traceback: Optional[str] = Field(default=None, description="The error traceback, if any")
- created_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was created")
- updated_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was last updated")
+ created_at: str = Field(description="The timestamp when the queue item was created")
+ updated_at: str = Field(description="The timestamp when the queue item was last updated")
started_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was started")
completed_at: Optional[str] = Field(default=None, description="The timestamp when the queue item was completed")
batch_status: BatchStatus = Field(description="The status of the batch")
@@ -253,13 +267,17 @@ def build(
queue_id=queue_item.queue_id,
item_id=queue_item.item_id,
batch_id=queue_item.batch_id,
+ origin=queue_item.origin,
+ destination=queue_item.destination,
+ user_id=queue_item.user_id,
session_id=queue_item.session_id,
status=queue_item.status,
+ status_sequence=queue_item.status_sequence,
error_type=queue_item.error_type,
error_message=queue_item.error_message,
error_traceback=queue_item.error_traceback,
- created_at=str(queue_item.created_at) if queue_item.created_at else None,
- updated_at=str(queue_item.updated_at) if queue_item.updated_at else None,
+ created_at=str(queue_item.created_at),
+ updated_at=str(queue_item.updated_at),
started_at=str(queue_item.started_at) if queue_item.started_at else None,
completed_at=str(queue_item.completed_at) if queue_item.completed_at else None,
batch_status=batch_status,
@@ -279,15 +297,35 @@ class BatchEnqueuedEvent(QueueEventBase):
description="The number of invocations initially requested to be enqueued (may be less than enqueued if queue was full)"
)
priority: int = Field(description="The priority of the batch")
+ origin: str | None = Field(default=None, description="The origin of the batch")
+ user_id: str = Field(default="system", description="The ID of the user who enqueued the batch")
@classmethod
- def build(cls, enqueue_result: EnqueueBatchResult) -> "BatchEnqueuedEvent":
+ def build(cls, enqueue_result: EnqueueBatchResult, user_id: str = "system") -> "BatchEnqueuedEvent":
return cls(
queue_id=enqueue_result.queue_id,
batch_id=enqueue_result.batch.batch_id,
+ origin=enqueue_result.batch.origin,
enqueued=enqueue_result.enqueued,
requested=enqueue_result.requested,
priority=enqueue_result.priority,
+ user_id=user_id,
+ )
+
+
+@payload_schema.register
+class QueueItemsRetriedEvent(QueueEventBase):
+ """Event model for queue_items_retried"""
+
+ __event_name__ = "queue_items_retried"
+
+ retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
+
+ @classmethod
+ def build(cls, retry_result: RetryItemsResult) -> "QueueItemsRetriedEvent":
+ return cls(
+ queue_id=retry_result.queue_id,
+ retried_item_ids=retry_result.retried_item_ids,
)
@@ -369,6 +407,17 @@ def build(cls, job: "DownloadJob") -> "DownloadCancelledEvent":
return cls(source=str(job.source))
+@payload_schema.register
+class DownloadPausedEvent(DownloadEventBase):
+ """Event model for download_paused"""
+
+ __event_name__ = "download_paused"
+
+ @classmethod
+ def build(cls, job: "DownloadJob") -> "DownloadPausedEvent":
+ return cls(source=str(job.source))
+
+
@payload_schema.register
class DownloadErrorEvent(DownloadEventBase):
"""Event model for download_error"""
@@ -424,7 +473,7 @@ class ModelInstallDownloadStartedEvent(ModelEventBase):
__event_name__ = "model_install_download_started"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
local_path: str = Field(description="Where model is downloading to")
bytes: int = Field(description="Number of bytes downloaded so far")
total_bytes: int = Field(description="Total size of download, including all files")
@@ -445,7 +494,7 @@ def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadStartedEvent":
]
return cls(
id=job.id,
- source=str(job.source),
+ source=job.source,
local_path=job.local_path.as_posix(),
parts=parts,
bytes=job.bytes,
@@ -460,7 +509,7 @@ class ModelInstallDownloadProgressEvent(ModelEventBase):
__event_name__ = "model_install_download_progress"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
local_path: str = Field(description="Where model is downloading to")
bytes: int = Field(description="Number of bytes downloaded so far")
total_bytes: int = Field(description="Total size of download, including all files")
@@ -481,7 +530,7 @@ def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadProgressEvent":
]
return cls(
id=job.id,
- source=str(job.source),
+ source=job.source,
local_path=job.local_path.as_posix(),
parts=parts,
bytes=job.bytes,
@@ -496,11 +545,11 @@ class ModelInstallDownloadsCompleteEvent(ModelEventBase):
__event_name__ = "model_install_downloads_complete"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
@classmethod
def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadsCompleteEvent":
- return cls(id=job.id, source=str(job.source))
+ return cls(id=job.id, source=job.source)
@payload_schema.register
@@ -510,11 +559,11 @@ class ModelInstallStartedEvent(ModelEventBase):
__event_name__ = "model_install_started"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
@classmethod
def build(cls, job: "ModelInstallJob") -> "ModelInstallStartedEvent":
- return cls(id=job.id, source=str(job.source))
+ return cls(id=job.id, source=job.source)
@payload_schema.register
@@ -524,14 +573,21 @@ class ModelInstallCompleteEvent(ModelEventBase):
__event_name__ = "model_install_complete"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
key: str = Field(description="Model config record key")
total_bytes: Optional[int] = Field(description="Size of the model (may be None for installation of a local path)")
+ config: AnyModelConfig = Field(description="The installed model's config")
@classmethod
def build(cls, job: "ModelInstallJob") -> "ModelInstallCompleteEvent":
assert job.config_out is not None
- return cls(id=job.id, source=str(job.source), key=(job.config_out.key), total_bytes=job.total_bytes)
+ return cls(
+ id=job.id,
+ source=job.source,
+ key=(job.config_out.key),
+ total_bytes=job.total_bytes,
+ config=job.config_out,
+ )
@payload_schema.register
@@ -541,11 +597,11 @@ class ModelInstallCancelledEvent(ModelEventBase):
__event_name__ = "model_install_cancelled"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
@classmethod
def build(cls, job: "ModelInstallJob") -> "ModelInstallCancelledEvent":
- return cls(id=job.id, source=str(job.source))
+ return cls(id=job.id, source=job.source)
@payload_schema.register
@@ -555,7 +611,7 @@ class ModelInstallErrorEvent(ModelEventBase):
__event_name__ = "model_install_error"
id: int = Field(description="The ID of the install job")
- source: str = Field(description="Source of the model; local path, repo_id or url")
+ source: ModelSource = Field(description="Source of the model; local path, repo_id or url")
error_type: str = Field(description="The name of the exception")
error: str = Field(description="A text description of the exception")
@@ -563,7 +619,7 @@ class ModelInstallErrorEvent(ModelEventBase):
def build(cls, job: "ModelInstallJob") -> "ModelInstallErrorEvent":
assert job.error_type is not None
assert job.error is not None
- return cls(id=job.id, source=str(job.source), error_type=job.error_type, error=job.error)
+ return cls(id=job.id, source=job.source, error_type=job.error_type, error=job.error)
class BulkDownloadEventBase(EventBase):
@@ -572,6 +628,7 @@ class BulkDownloadEventBase(EventBase):
bulk_download_id: str = Field(description="The ID of the bulk image download")
bulk_download_item_id: str = Field(description="The ID of the bulk image download item")
bulk_download_item_name: str = Field(description="The name of the bulk image download item")
+ user_id: str = Field(default="system", description="The ID of the user who initiated the download")
@payload_schema.register
@@ -582,12 +639,17 @@ class BulkDownloadStartedEvent(BulkDownloadEventBase):
@classmethod
def build(
- cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ cls,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> "BulkDownloadStartedEvent":
return cls(
bulk_download_id=bulk_download_id,
bulk_download_item_id=bulk_download_item_id,
bulk_download_item_name=bulk_download_item_name,
+ user_id=user_id,
)
@@ -599,12 +661,17 @@ class BulkDownloadCompleteEvent(BulkDownloadEventBase):
@classmethod
def build(
- cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str
+ cls,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ user_id: str = "system",
) -> "BulkDownloadCompleteEvent":
return cls(
bulk_download_id=bulk_download_id,
bulk_download_item_id=bulk_download_item_id,
bulk_download_item_name=bulk_download_item_name,
+ user_id=user_id,
)
@@ -618,11 +685,31 @@ class BulkDownloadErrorEvent(BulkDownloadEventBase):
@classmethod
def build(
- cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, error: str
+ cls,
+ bulk_download_id: str,
+ bulk_download_item_id: str,
+ bulk_download_item_name: str,
+ error: str,
+ user_id: str = "system",
) -> "BulkDownloadErrorEvent":
return cls(
bulk_download_id=bulk_download_id,
bulk_download_item_id=bulk_download_item_id,
bulk_download_item_name=bulk_download_item_name,
error=error,
+ user_id=user_id,
)
+
+
+@payload_schema.register
+class RecallParametersUpdatedEvent(QueueEventBase):
+ """Event model for recall_parameters_updated"""
+
+ __event_name__ = "recall_parameters_updated"
+
+ user_id: str = Field(description="The ID of the user whose recall parameters were updated")
+ parameters: dict[str, Any] = Field(description="The recall parameters that were updated")
+
+ @classmethod
+ def build(cls, queue_id: str, user_id: str, parameters: dict[str, Any]) -> "RecallParametersUpdatedEvent":
+ return cls(queue_id=queue_id, user_id=user_id, parameters=parameters)
diff --git a/invokeai/app/services/events/events_fastapievents.py b/invokeai/app/services/events/events_fastapievents.py
index 8279d3bb344..90e1402773d 100644
--- a/invokeai/app/services/events/events_fastapievents.py
+++ b/invokeai/app/services/events/events_fastapievents.py
@@ -1,47 +1,54 @@
-# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
-
import asyncio
import threading
-from queue import Empty, Queue
from fastapi_events.dispatcher import dispatch
-from invokeai.app.services.events.events_common import (
- EventBase,
-)
-
-from .events_base import EventServiceBase
+from invokeai.app.services.events.events_base import EventServiceBase
+from invokeai.app.services.events.events_common import EventBase
class FastAPIEventService(EventServiceBase):
- def __init__(self, event_handler_id: int) -> None:
+ def __init__(self, event_handler_id: int, loop: asyncio.AbstractEventLoop) -> None:
self.event_handler_id = event_handler_id
- self._queue = Queue[EventBase | None]()
+ self._queue = asyncio.Queue[EventBase | None]()
self._stop_event = threading.Event()
- asyncio.create_task(self._dispatch_from_queue(stop_event=self._stop_event))
+ self._loop = loop
+
+ # We need to store a reference to the task so it doesn't get GC'd
+ # See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
+ self._background_tasks: set[asyncio.Task[None]] = set()
+ task = self._loop.create_task(self._dispatch_from_queue(stop_event=self._stop_event))
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.remove)
super().__init__()
def stop(self, *args, **kwargs):
self._stop_event.set()
- self._queue.put(None)
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, None)
def dispatch(self, event: EventBase) -> None:
- self._queue.put(event)
+ if self._loop.is_closed():
+ # The event loop was closed during shutdown. Events can no longer be dispatched;
+ # silently drop this one so the generation thread can wind down cleanly.
+ return
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
async def _dispatch_from_queue(self, stop_event: threading.Event):
"""Get events on from the queue and dispatch them, from the correct thread"""
while not stop_event.is_set():
try:
- event = self._queue.get(block=False)
+ event = await self._queue.get()
if not event: # Probably stopping
continue
# Leave the payloads as live pydantic models
dispatch(event, middleware_id=self.event_handler_id, payload_schema_dump=False)
- except Empty:
- await asyncio.sleep(0.1)
- pass
-
except asyncio.CancelledError as e:
raise e # Raise a proper error
+ except Exception:
+ import logging
+
+ logging.getLogger("InvokeAI").error(
+ f"Error dispatching event {getattr(event, '__event_name__', event)}", exc_info=True
+ )
diff --git a/invokeai/app/services/external_generation/__init__.py b/invokeai/app/services/external_generation/__init__.py
new file mode 100644
index 00000000000..b933811d293
--- /dev/null
+++ b/invokeai/app/services/external_generation/__init__.py
@@ -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",
+]
diff --git a/invokeai/app/services/external_generation/errors.py b/invokeai/app/services/external_generation/errors.py
new file mode 100644
index 00000000000..f61a6a8c730
--- /dev/null
+++ b/invokeai/app/services/external_generation/errors.py
@@ -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
diff --git a/invokeai/app/services/external_generation/external_generation_base.py b/invokeai/app/services/external_generation/external_generation_base.py
new file mode 100644
index 00000000000..2145ff5ca42
--- /dev/null
+++ b/invokeai/app/services/external_generation/external_generation_base.py
@@ -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
diff --git a/invokeai/app/services/external_generation/external_generation_common.py b/invokeai/app/services/external_generation/external_generation_common.py
new file mode 100644
index 00000000000..f14bff52dd2
--- /dev/null
+++ b/invokeai/app/services/external_generation/external_generation_common.py
@@ -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
diff --git a/invokeai/app/services/external_generation/external_generation_default.py b/invokeai/app/services/external_generation/external_generation_default.py
new file mode 100644
index 00000000000..d6a266753b3
--- /dev/null
+++ b/invokeai/app/services/external_generation/external_generation_default.py
@@ -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)
diff --git a/invokeai/app/services/external_generation/image_utils.py b/invokeai/app/services/external_generation/image_utils.py
new file mode 100644
index 00000000000..a23c1f11d66
--- /dev/null
+++ b/invokeai/app/services/external_generation/image_utils.py
@@ -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")
diff --git a/invokeai/app/services/external_generation/providers/__init__.py b/invokeai/app/services/external_generation/providers/__init__.py
new file mode 100644
index 00000000000..9926302addf
--- /dev/null
+++ b/invokeai/app/services/external_generation/providers/__init__.py
@@ -0,0 +1,6 @@
+from invokeai.app.services.external_generation.providers.alibabacloud import AlibabaCloudProvider
+from invokeai.app.services.external_generation.providers.gemini import GeminiProvider
+from invokeai.app.services.external_generation.providers.openai import OpenAIProvider
+from invokeai.app.services.external_generation.providers.seedream import SeedreamProvider
+
+__all__ = ["AlibabaCloudProvider", "GeminiProvider", "OpenAIProvider", "SeedreamProvider"]
diff --git a/invokeai/app/services/external_generation/providers/alibabacloud.py b/invokeai/app/services/external_generation/providers/alibabacloud.py
new file mode 100644
index 00000000000..6a1d01baefa
--- /dev/null
+++ b/invokeai/app/services/external_generation/providers/alibabacloud.py
@@ -0,0 +1,410 @@
+from __future__ import annotations
+
+import io
+import time
+
+import requests
+from PIL import Image
+from PIL.Image import Image as PILImageType
+
+from invokeai.app.services.external_generation.errors import 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
+
+# Models that support the synchronous multimodal-generation endpoint with messages format
+_SYNC_MODELS = {
+ "qwen-image-2.0-pro",
+ "qwen-image-2.0",
+ "qwen-image-max",
+ "wan2.6-t2i",
+ "qwen-image-edit-max",
+}
+
+# Models that use the async image-generation endpoint with flat prompt format.
+# Currently no shipped starter model uses this path, but it is retained because
+# users may install custom external models via `external://alibabacloud/`.
+_ASYNC_MODELS: set[str] = set()
+
+_TASK_POLL_INTERVAL = 5 # seconds
+_TASK_POLL_TIMEOUT = 300 # seconds
+_DOWNLOAD_TIMEOUT = 60 # seconds
+_DOWNLOAD_MAX_BYTES = 32 * 1024 * 1024 # 32 MiB safety cap on image downloads
+_RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
+_MAX_RETRIES = 2 # total attempts = 1 + _MAX_RETRIES
+_RETRY_BACKOFF_BASE = 2.0 # seconds
+
+
+class AlibabaCloudProvider(ExternalProvider):
+ provider_id = "alibabacloud"
+
+ def is_configured(self) -> bool:
+ return bool(self._app_config.external_alibabacloud_api_key)
+
+ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
+ api_key = self._app_config.external_alibabacloud_api_key
+ if not api_key:
+ raise ExternalProviderRequestError("Alibaba Cloud DashScope API key is not configured")
+
+ base_url = (self._app_config.external_alibabacloud_base_url or "https://dashscope-intl.aliyuncs.com").rstrip(
+ "/"
+ )
+ model_id = request.model.provider_model_id
+ headers = {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ }
+ size = f"{request.width}*{request.height}"
+
+ if model_id in _SYNC_MODELS:
+ return self._generate_sync(request, base_url, headers, model_id, size)
+ if model_id in _ASYNC_MODELS:
+ return self._generate_async(request, base_url, headers, model_id, size)
+ raise ExternalProviderRequestError(
+ f"Unknown DashScope model_id '{model_id}'. Add it to _SYNC_MODELS or _ASYNC_MODELS in alibabacloud.py."
+ )
+
+ def _generate_sync(
+ self,
+ request: ExternalGenerationRequest,
+ base_url: str,
+ headers: dict[str, str],
+ model_id: str,
+ size: str,
+ ) -> ExternalGenerationResult:
+ """Use the synchronous multimodal-generation endpoint (messages format)."""
+ endpoint = f"{base_url}/api/v1/services/aigc/multimodal-generation/generation"
+
+ content: list[dict[str, str]] = []
+
+ # Reference images: DashScope multimodal accepts up to 3 input images for the
+ # qwen-image-edit family; we let the API surface its own limit if exceeded.
+ for ref in request.reference_images:
+ content.append({"image": f"data:image/png;base64,{encode_image_base64(ref.image)}"})
+
+ content.append({"text": request.prompt})
+
+ parameters: dict[str, object] = {
+ "size": size,
+ "n": request.num_images,
+ "prompt_extend": False,
+ "watermark": False,
+ }
+ if request.seed is not None:
+ parameters["seed"] = request.seed
+
+ payload: dict[str, object] = {
+ "model": model_id,
+ "input": {
+ "messages": [
+ {
+ "role": "user",
+ "content": content,
+ }
+ ]
+ },
+ "parameters": parameters,
+ }
+
+ response = self._post_with_retry(endpoint, headers=headers, json=payload, timeout=120, label="DashScope sync")
+ if not response.ok:
+ raise ExternalProviderRequestError(
+ f"DashScope request failed with status {response.status_code} for model '{model_id}': {response.text}"
+ )
+
+ data = response.json()
+ request_id = data.get("request_id")
+ return self._parse_sync_response(data, request, request_id)
+
+ def _generate_async(
+ self,
+ request: ExternalGenerationRequest,
+ base_url: str,
+ headers: dict[str, str],
+ model_id: str,
+ size: str,
+ ) -> ExternalGenerationResult:
+ """Use the async image-generation endpoint (flat prompt format) with task polling."""
+ endpoint = f"{base_url}/api/v1/services/aigc/image-generation/generation"
+ async_headers = {**headers, "X-DashScope-Async": "enable"}
+
+ parameters: dict[str, object] = {
+ "size": size,
+ "n": request.num_images,
+ "prompt_extend": False,
+ "watermark": False,
+ }
+ if request.seed is not None:
+ parameters["seed"] = request.seed
+
+ input_data: dict[str, object] = {"prompt": request.prompt}
+
+ payload: dict[str, object] = {
+ "model": model_id,
+ "input": input_data,
+ "parameters": parameters,
+ }
+
+ response = self._post_with_retry(
+ endpoint, headers=async_headers, json=payload, timeout=60, label="DashScope async submit"
+ )
+ if not response.ok:
+ raise ExternalProviderRequestError(
+ f"DashScope async request failed with status {response.status_code} for model '{model_id}': {response.text}"
+ )
+
+ data = response.json()
+ request_id = data.get("request_id")
+ output = data.get("output", {})
+ task_id = output.get("task_id")
+
+ if not task_id:
+ raise ExternalProviderRequestError(f"DashScope async response missing task_id: {data}")
+
+ return self._poll_task(base_url, headers, task_id, request, request_id)
+
+ def _poll_task(
+ self,
+ base_url: str,
+ headers: dict[str, str],
+ task_id: str,
+ request: ExternalGenerationRequest,
+ request_id: str | None,
+ ) -> ExternalGenerationResult:
+ """Poll an async task until completion."""
+ task_url = f"{base_url}/api/v1/tasks/{task_id}"
+ start_time = time.monotonic()
+ poll_headers = {"Authorization": headers["Authorization"]}
+ first_poll = True
+
+ while True:
+ elapsed = time.monotonic() - start_time
+ if elapsed > _TASK_POLL_TIMEOUT:
+ raise ExternalProviderRequestError(f"DashScope task {task_id} timed out after {_TASK_POLL_TIMEOUT}s")
+
+ response = self._get_with_retry(task_url, headers=poll_headers, timeout=30, label="DashScope task poll")
+ if not response.ok:
+ raise ExternalProviderRequestError(
+ f"DashScope task poll failed with status {response.status_code}: {response.text}"
+ )
+
+ data = response.json()
+ output = data.get("output", {})
+ status = output.get("task_status")
+
+ if first_poll:
+ self._logger.info("DashScope task %s submitted (status=%s)", task_id, status)
+ first_poll = False
+
+ if status == "SUCCEEDED":
+ return self._parse_async_response(output, request, request_id)
+ if status in ("FAILED", "UNKNOWN"):
+ message = output.get("message", "Unknown error")
+ raise ExternalProviderRequestError(f"DashScope task {task_id} failed: {message}")
+
+ self._logger.debug("DashScope task %s status: %s (%.0fs elapsed)", task_id, status, elapsed)
+ time.sleep(_TASK_POLL_INTERVAL)
+
+ def _parse_sync_response(
+ self,
+ data: dict[str, object],
+ request: ExternalGenerationRequest,
+ request_id: str | None,
+ ) -> ExternalGenerationResult:
+ """Parse the synchronous multimodal-generation response."""
+ output = data.get("output")
+ if not isinstance(output, dict):
+ raise ExternalProviderRequestError(f"DashScope response missing output: {data}")
+
+ choices = output.get("choices")
+ if not isinstance(choices, list):
+ raise ExternalProviderRequestError(f"DashScope response missing choices: {data}")
+
+ images: list[ExternalGeneratedImage] = []
+ for choice in choices:
+ if not isinstance(choice, dict):
+ continue
+ message = choice.get("message")
+ if not isinstance(message, dict):
+ continue
+ content = message.get("content")
+ if not isinstance(content, list):
+ continue
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ image_url = part.get("image")
+ if isinstance(image_url, str) and image_url:
+ pil_image = self._download_image(image_url)
+ images.append(ExternalGeneratedImage(image=pil_image, seed=request.seed))
+
+ if not images:
+ raise ExternalProviderRequestError(f"DashScope response contained no images: {data}")
+
+ return ExternalGenerationResult(
+ images=images,
+ seed_used=request.seed,
+ provider_request_id=request_id,
+ provider_metadata={"model": request.model.provider_model_id},
+ )
+
+ def _parse_async_response(
+ self,
+ output: dict[str, object],
+ request: ExternalGenerationRequest,
+ request_id: str | None,
+ ) -> ExternalGenerationResult:
+ """Parse the async task completion response."""
+ results = output.get("results")
+ if not isinstance(results, list):
+ raise ExternalProviderRequestError(f"DashScope async response missing results: {output}")
+
+ images: list[ExternalGeneratedImage] = []
+ for result in results:
+ if not isinstance(result, dict):
+ continue
+ url = result.get("url")
+ if isinstance(url, str) and url:
+ pil_image = self._download_image(url)
+ images.append(ExternalGeneratedImage(image=pil_image, seed=request.seed))
+ continue
+ b64_image = result.get("b64_image")
+ if isinstance(b64_image, str) and b64_image:
+ pil_image = decode_image_base64(b64_image)
+ images.append(ExternalGeneratedImage(image=pil_image, seed=request.seed))
+
+ if not images:
+ raise ExternalProviderRequestError(f"DashScope async response contained no images: {output}")
+
+ return ExternalGenerationResult(
+ images=images,
+ seed_used=request.seed,
+ provider_request_id=request_id,
+ provider_metadata={"model": request.model.provider_model_id},
+ )
+
+ def _download_image(self, url: str) -> PILImageType:
+ """Download an image from a URL and return it as a PIL Image, with a size cap."""
+ try:
+ response = requests.get(url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
+ except requests.RequestException as exc:
+ raise ExternalProviderRequestError(f"Failed to download image from DashScope: {exc}") from exc
+
+ with response:
+ if not response.ok:
+ raise ExternalProviderRequestError(
+ f"Failed to download image from DashScope (status {response.status_code})"
+ )
+
+ content_length = response.headers.get("Content-Length")
+ if content_length is not None:
+ try:
+ if int(content_length) > _DOWNLOAD_MAX_BYTES:
+ raise ExternalProviderRequestError(
+ f"DashScope image exceeds {_DOWNLOAD_MAX_BYTES} byte cap (Content-Length={content_length})"
+ )
+ except ValueError:
+ pass
+
+ buffer = bytearray()
+ for chunk in response.iter_content(chunk_size=64 * 1024):
+ if not chunk:
+ continue
+ buffer.extend(chunk)
+ if len(buffer) > _DOWNLOAD_MAX_BYTES:
+ raise ExternalProviderRequestError(f"DashScope image exceeds {_DOWNLOAD_MAX_BYTES} byte cap")
+
+ return Image.open(io.BytesIO(bytes(buffer))).convert("RGB")
+
+ def _post_with_retry(
+ self,
+ url: str,
+ *,
+ headers: dict[str, str],
+ json: dict,
+ timeout: int,
+ label: str,
+ ) -> requests.Response:
+ return self._request_with_retry("POST", url, headers=headers, json=json, timeout=timeout, label=label)
+
+ def _get_with_retry(
+ self,
+ url: str,
+ *,
+ headers: dict[str, str],
+ timeout: int,
+ label: str,
+ ) -> requests.Response:
+ return self._request_with_retry("GET", url, headers=headers, timeout=timeout, label=label)
+
+ def _request_with_retry(
+ self,
+ method: str,
+ url: str,
+ *,
+ headers: dict[str, str],
+ timeout: int,
+ label: str,
+ json: dict | None = None,
+ ) -> requests.Response:
+ """Issue a request with limited retries on transient failures (429/5xx, network errors).
+
+ Honors `Retry-After` for 429 responses when present. Non-retryable errors
+ (4xx other than 429, parse failures) are returned to the caller, which is
+ responsible for raising a meaningful ExternalProviderRequestError.
+ """
+ last_exc: Exception | None = None
+ for attempt in range(_MAX_RETRIES + 1):
+ try:
+ if method == "POST":
+ response = requests.post(url, headers=headers, json=json, timeout=timeout)
+ else:
+ response = requests.get(url, headers=headers, timeout=timeout)
+ except requests.RequestException as exc:
+ last_exc = exc
+ if attempt >= _MAX_RETRIES:
+ raise ExternalProviderRequestError(f"{label} network error: {exc}") from exc
+ delay = _RETRY_BACKOFF_BASE * (2**attempt)
+ self._logger.warning(
+ "%s network error on attempt %d/%d: %s — retrying in %.1fs",
+ label,
+ attempt + 1,
+ _MAX_RETRIES + 1,
+ exc,
+ delay,
+ )
+ time.sleep(delay)
+ continue
+
+ if response.status_code in _RETRY_STATUS_CODES and attempt < _MAX_RETRIES:
+ delay = self._retry_delay(response, attempt)
+ self._logger.warning(
+ "%s got status %d on attempt %d/%d — retrying in %.1fs",
+ label,
+ response.status_code,
+ attempt + 1,
+ _MAX_RETRIES + 1,
+ delay,
+ )
+ time.sleep(delay)
+ continue
+
+ return response
+
+ # Unreachable: the loop either returns a response or raises.
+ assert last_exc is not None
+ raise ExternalProviderRequestError(f"{label} failed after retries: {last_exc}") from last_exc
+
+ @staticmethod
+ def _retry_delay(response: requests.Response, attempt: int) -> float:
+ retry_after = response.headers.get("Retry-After")
+ if retry_after:
+ try:
+ return max(0.0, float(retry_after))
+ except ValueError:
+ pass
+ return _RETRY_BACKOFF_BASE * (2**attempt)
diff --git a/invokeai/app/services/external_generation/providers/gemini.py b/invokeai/app/services/external_generation/providers/gemini.py
new file mode 100644
index 00000000000..de2cf0e85a7
--- /dev/null
+++ b/invokeai/app/services/external_generation/providers/gemini.py
@@ -0,0 +1,248 @@
+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,
+ }
+ if "thinking_level" in opts:
+ payload["thinkingConfig"] = {"thinkingLevel": opts["thinking_level"].upper()}
+
+ 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
diff --git a/invokeai/app/services/external_generation/providers/openai.py b/invokeai/app/services/external_generation/providers/openai.py
new file mode 100644
index 00000000000..7e8252b43d3
--- /dev/null
+++ b/invokeai/app/services/external_generation/providers/openai.py
@@ -0,0 +1,162 @@
+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", "gpt-image-2"}
+ _DEFAULT_TIMEOUT = 120
+ _MODEL_TIMEOUTS: dict[str, int] = {"gpt-image-2": 300}
+
+ 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
+ timeout = self._MODEL_TIMEOUTS.get(model_id, self._DEFAULT_TIMEOUT)
+ 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=timeout,
+ )
+ 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=timeout,
+ )
+
+ 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
diff --git a/invokeai/app/services/external_generation/providers/seedream.py b/invokeai/app/services/external_generation/providers/seedream.py
new file mode 100644
index 00000000000..13b05af7e38
--- /dev/null
+++ b/invokeai/app/services/external_generation/providers/seedream.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import requests
+
+from invokeai.app.services.external_generation.errors import (
+ ExternalProviderCapabilityError,
+ 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
+
+_SEEDREAM_BATCH_PREFIXES = (
+ "seedream-5",
+ "seedream-4.5",
+ "seedream-4.0",
+ "seedream-4-5",
+ "seedream-4-0",
+ "seedream-5-0",
+)
+
+# Seedream batch endpoint accepts up to 15 total images counting both inputs (reference + init)
+# and outputs combined. Hitting this only after the API call wastes a request and produces a
+# confusing 400, so we enforce it locally for batch-capable models.
+_SEEDREAM_BATCH_MAX_TOTAL_IMAGES = 15
+
+
+class SeedreamProvider(ExternalProvider):
+ provider_id = "seedream"
+
+ def is_configured(self) -> bool:
+ return bool(self._app_config.external_seedream_api_key)
+
+ def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
+ api_key = self._app_config.external_seedream_api_key
+ if not api_key:
+ raise ExternalProviderRequestError("Seedream API key is not configured")
+
+ base_url = (self._app_config.external_seedream_base_url or "https://ark.ap-southeast.bytepluses.com").rstrip(
+ "/"
+ )
+ endpoint = f"{base_url}/api/v3/images/generations"
+ headers = {"Authorization": f"Bearer {api_key}"}
+
+ model_id = request.model.provider_model_id
+ is_batch_model = any(model_id.startswith(prefix) for prefix in _SEEDREAM_BATCH_PREFIXES)
+
+ if is_batch_model:
+ input_image_count = len(request.reference_images) + (1 if request.init_image is not None else 0)
+ total_images = input_image_count + request.num_images
+ if total_images > _SEEDREAM_BATCH_MAX_TOTAL_IMAGES:
+ raise ExternalProviderCapabilityError(
+ f"{request.model.name} supports at most {_SEEDREAM_BATCH_MAX_TOTAL_IMAGES} images total "
+ f"(reference + init + output), got {total_images}"
+ )
+
+ opts = request.provider_options or {}
+
+ payload: dict[str, object] = {
+ "model": model_id,
+ "prompt": request.prompt,
+ "size": f"{request.width}x{request.height}",
+ "response_format": "b64_json",
+ "watermark": opts.get("watermark", False),
+ }
+
+ if opts.get("optimize_prompt"):
+ payload["optimize_prompt_options"] = {"optimize_prompt": True}
+
+ # Seed and guidance_scale are only supported on 3.0 models
+ if not is_batch_model and request.seed is not None and request.seed >= 0:
+ payload["seed"] = request.seed
+ if not is_batch_model and opts.get("guidance_scale") is not None:
+ payload["guidance_scale"] = opts["guidance_scale"]
+
+ # Batch generation for 4.x/5.x models
+ if is_batch_model:
+ if request.num_images > 1:
+ payload["sequential_image_generation"] = "auto"
+ payload["sequential_image_generation_options"] = {"max_images": request.num_images}
+ else:
+ payload["sequential_image_generation"] = "disabled"
+
+ # Image input: init_image for img2img, reference images for 4.x
+ images_b64: list[str] = []
+ if request.init_image is not None:
+ images_b64.append(f"data:image/png;base64,{encode_image_base64(request.init_image)}")
+ for reference in request.reference_images:
+ images_b64.append(f"data:image/png;base64,{encode_image_base64(reference.image)}")
+
+ if images_b64:
+ payload["image"] = images_b64 if len(images_b64) > 1 else images_b64[0]
+
+ response = requests.post(endpoint, headers=headers, 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"Seedream 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"Seedream request failed with status {response.status_code}: {response.text}"
+ )
+
+ body = response.json()
+ if not isinstance(body, dict):
+ raise ExternalProviderRequestError("Seedream response payload was not a JSON object")
+
+ generated_images: list[ExternalGeneratedImage] = []
+ item_errors: list[dict[str, object]] = []
+ data_items = body.get("data")
+ if not isinstance(data_items, list):
+ raise ExternalProviderRequestError("Seedream response payload missing image data")
+
+ for item in data_items:
+ if not isinstance(item, dict):
+ continue
+ # Items may be error objects for failed images in batch — collect rather than discard
+ # so partial-failure causes (e.g., content filter) are visible to the caller.
+ if "error" in item:
+ error_payload = item["error"]
+ item_errors.append(
+ error_payload if isinstance(error_payload, dict) else {"message": str(error_payload)}
+ )
+ continue
+ encoded = item.get("b64_json")
+ if not encoded:
+ continue
+ image = decode_image_base64(encoded)
+ generated_images.append(ExternalGeneratedImage(image=image, seed=request.seed))
+
+ if not generated_images:
+ if item_errors:
+ first = item_errors[0]
+ message = first.get("message") if isinstance(first, dict) else None
+ raise ExternalProviderRequestError(
+ f"Seedream returned no images. Provider reported: {message or item_errors}"
+ )
+ raise ExternalProviderRequestError("Seedream response contained no images")
+
+ provider_metadata: dict[str, object] = {"model": model_id}
+ if item_errors:
+ provider_metadata["partial_failures"] = item_errors
+ self._logger.warning(
+ "Seedream returned %d image(s) with %d partial failure(s): %s",
+ len(generated_images),
+ len(item_errors),
+ item_errors,
+ )
+
+ return ExternalGenerationResult(
+ images=generated_images,
+ seed_used=request.seed,
+ provider_metadata=provider_metadata,
+ )
+
+
+def _parse_retry_after(value: str | None) -> float | None:
+ if not value:
+ return None
+ try:
+ return float(value)
+ except ValueError:
+ return None
diff --git a/invokeai/app/services/external_generation/startup.py b/invokeai/app/services/external_generation/startup.py
new file mode 100644
index 00000000000..a95e6c94180
--- /dev/null
+++ b/invokeai/app/services/external_generation/startup.py
@@ -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
diff --git a/invokeai/app/services/image_files/image_files_base.py b/invokeai/app/services/image_files/image_files_base.py
index dc6609aa48c..7464cd7941d 100644
--- a/invokeai/app/services/image_files/image_files_base.py
+++ b/invokeai/app/services/image_files/image_files_base.py
@@ -9,12 +9,12 @@ class ImageFileStorageBase(ABC):
"""Low-level service responsible for storing and retrieving image files."""
@abstractmethod
- def get(self, image_name: str) -> PILImageType:
+ def get(self, image_name: str, image_subfolder: str = "") -> PILImageType:
"""Retrieves an image as PIL Image."""
pass
@abstractmethod
- def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
+ def get_path(self, image_name: str, thumbnail: bool = False, image_subfolder: str = "") -> Path:
"""Gets the internal path to an image or thumbnail."""
pass
@@ -34,21 +34,22 @@ def save(
workflow: Optional[str] = None,
graph: Optional[str] = None,
thumbnail_size: int = 256,
+ image_subfolder: str = "",
) -> None:
"""Saves an image and a 256x256 WEBP thumbnail. Returns a tuple of the image name, thumbnail name, and created timestamp."""
pass
@abstractmethod
- def delete(self, image_name: str) -> None:
+ def delete(self, image_name: str, image_subfolder: str = "") -> None:
"""Deletes an image and its thumbnail (if one exists)."""
pass
@abstractmethod
- def get_workflow(self, image_name: str) -> Optional[str]:
+ def get_workflow(self, image_name: str, image_subfolder: str = "") -> Optional[str]:
"""Gets the workflow of an image."""
pass
@abstractmethod
- def get_graph(self, image_name: str) -> Optional[str]:
+ def get_graph(self, image_name: str, image_subfolder: str = "") -> Optional[str]:
"""Gets the graph of an image."""
pass
diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py
index 15d0be31f8d..ec84439547a 100644
--- a/invokeai/app/services/image_files/image_files_disk.py
+++ b/invokeai/app/services/image_files/image_files_disk.py
@@ -1,34 +1,34 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team
+import threading
from pathlib import Path
from queue import Queue
-from typing import Dict, Optional, Union
+from typing import Optional, Union
from PIL import Image, PngImagePlugin
from PIL.Image import Image as PILImageType
-from send2trash import send2trash
+from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
+from invokeai.app.services.image_files.image_files_common import (
+ ImageFileDeleteException,
+ ImageFileNotFoundException,
+ ImageFileSaveException,
+)
from invokeai.app.services.invoker import Invoker
from invokeai.app.util.thumbnails import get_thumbnail_name, make_thumbnail
-from .image_files_base import ImageFileStorageBase
-from .image_files_common import ImageFileDeleteException, ImageFileNotFoundException, ImageFileSaveException
-
class DiskImageFileStorage(ImageFileStorageBase):
"""Stores images on disk"""
- __output_folder: Path
- __cache_ids: Queue # TODO: this is an incredibly naive cache
- __cache: Dict[Path, PILImageType]
- __max_cache_size: int
- __invoker: Invoker
-
def __init__(self, output_folder: Union[str, Path]):
- self.__cache = {}
- self.__cache_ids = Queue()
+ self.__cache: dict[Path, PILImageType] = {}
+ self.__cache_ids = Queue[Path]()
self.__max_cache_size = 10 # TODO: get this from config
+ # Guards the cache structures (__cache / __cache_ids), which are read and mutated from
+ # multiple session-processor worker threads in multi-GPU parallel mode.
+ self.__cache_lock = threading.Lock()
- self.__output_folder: Path = output_folder if isinstance(output_folder, Path) else Path(output_folder)
+ self.__output_folder = output_folder if isinstance(output_folder, Path) else Path(output_folder)
self.__thumbnails_folder = self.__output_folder / "thumbnails"
# Validate required output folders at launch
self.__validate_storage_folders()
@@ -36,15 +36,22 @@ def __init__(self, output_folder: Union[str, Path]):
def start(self, invoker: Invoker) -> None:
self.__invoker = invoker
- def get(self, image_name: str) -> PILImageType:
+ def get(self, image_name: str, image_subfolder: str = "") -> PILImageType:
try:
- image_path = self.get_path(image_name)
+ image_path = self.get_path(image_name, image_subfolder=image_subfolder)
cache_item = self.__get_cache(image_path)
if cache_item:
return cache_item
image = Image.open(image_path)
+ # Image.open() is lazy: it reads the header but defers pixel decoding (and holds the
+ # file handle open) until the first .load()/.copy()/.convert(). The opened object is
+ # cached and the SAME object is handed to every caller, so in multi-GPU parallel mode
+ # two worker threads can call .copy() on it concurrently and race on the shared file
+ # handle and decoder state, producing "broken data stream" / "self.png is not None"
+ # errors. Forcing the decode here makes the cached object safe for concurrent reads.
+ image.load()
self.__set_cache(image_path, image)
return image
except FileNotFoundError as e:
@@ -58,10 +65,14 @@ def save(
workflow: Optional[str] = None,
graph: Optional[str] = None,
thumbnail_size: int = 256,
+ image_subfolder: str = "",
) -> None:
try:
self.__validate_storage_folders()
- image_path = self.get_path(image_name)
+ image_path = self.get_path(image_name, image_subfolder=image_subfolder)
+
+ # Ensure subfolder directories exist
+ image_path.parent.mkdir(parents=True, exist_ok=True)
pnginfo = PngImagePlugin.PngInfo()
info_dict = {}
@@ -86,7 +97,11 @@ def save(
)
thumbnail_name = get_thumbnail_name(image_name)
- thumbnail_path = self.get_path(thumbnail_name, thumbnail=True)
+ thumbnail_path = self.get_path(thumbnail_name, thumbnail=True, image_subfolder=image_subfolder)
+
+ # Ensure thumbnail subfolder directories exist
+ thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
+
thumbnail_image = make_thumbnail(image, thumbnail_size)
thumbnail_image.save(thumbnail_path)
@@ -95,49 +110,82 @@ def save(
except Exception as e:
raise ImageFileSaveException from e
- def delete(self, image_name: str) -> None:
+ def delete(self, image_name: str, image_subfolder: str = "") -> None:
try:
- image_path = self.get_path(image_name)
+ image_path = self.get_path(image_name, image_subfolder=image_subfolder)
if image_path.exists():
- send2trash(image_path)
- if image_path in self.__cache:
- del self.__cache[image_path]
+ image_path.unlink()
thumbnail_name = get_thumbnail_name(image_name)
- thumbnail_path = self.get_path(thumbnail_name, True)
+ thumbnail_path = self.get_path(thumbnail_name, True, image_subfolder=image_subfolder)
if thumbnail_path.exists():
- send2trash(thumbnail_path)
- if thumbnail_path in self.__cache:
- del self.__cache[thumbnail_path]
+ thumbnail_path.unlink()
+
+ with self.__cache_lock:
+ if image_path in self.__cache:
+ del self.__cache[image_path]
+ if thumbnail_path in self.__cache:
+ del self.__cache[thumbnail_path]
except Exception as e:
raise ImageFileDeleteException from e
- # TODO: make this a bit more flexible for e.g. cloud storage
- def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
- path = self.__output_folder / image_name
-
- if thumbnail:
- thumbnail_name = get_thumbnail_name(image_name)
- path = self.__thumbnails_folder / thumbnail_name
-
- return path
+ def get_path(self, image_name: str, thumbnail: bool = False, image_subfolder: str = "") -> Path:
+ base_folder = self.__thumbnails_folder if thumbnail else self.__output_folder
+ filename = get_thumbnail_name(image_name) if thumbnail else image_name
+
+ # Validate the filename itself (no path separators allowed in the filename)
+ basename = Path(filename).name
+ if basename != filename:
+ raise ValueError("Invalid image name, potential directory traversal detected")
+
+ # Build the full path with optional subfolder
+ if image_subfolder:
+ self._validate_subfolder(image_subfolder)
+ image_path = base_folder / image_subfolder / basename
+ else:
+ image_path = base_folder / basename
+
+ # Ensure the image path is within the base folder to prevent directory traversal
+ resolved_base = base_folder.resolve()
+ resolved_image_path = image_path.resolve()
+
+ if not resolved_image_path.is_relative_to(resolved_base):
+ raise ValueError("Image path outside outputs folder, potential directory traversal detected")
+
+ return resolved_image_path
+
+ @staticmethod
+ def _validate_subfolder(subfolder: str) -> None:
+ """Validates a subfolder path to prevent directory traversal while allowing controlled subdirectories."""
+ if not subfolder:
+ return
+ if "\\" in subfolder:
+ raise ValueError("Backslashes not allowed in subfolder path")
+ if subfolder.startswith("/"):
+ raise ValueError("Absolute paths not allowed in subfolder path")
+ parts = subfolder.split("/")
+ for part in parts:
+ if part == "..":
+ raise ValueError("Parent directory references not allowed in subfolder path")
+ if part == "":
+ raise ValueError("Empty path segments not allowed in subfolder path")
def validate_path(self, path: Union[str, Path]) -> bool:
"""Validates the path given for an image or thumbnail."""
path = path if isinstance(path, Path) else Path(path)
return path.exists()
- def get_workflow(self, image_name: str) -> str | None:
- image = self.get(image_name)
+ def get_workflow(self, image_name: str, image_subfolder: str = "") -> str | None:
+ image = self.get(image_name, image_subfolder=image_subfolder)
workflow = image.info.get("invokeai_workflow", None)
if isinstance(workflow, str):
return workflow
return None
- def get_graph(self, image_name: str) -> str | None:
- image = self.get(image_name)
+ def get_graph(self, image_name: str, image_subfolder: str = "") -> str | None:
+ image = self.get(image_name, image_subfolder=image_subfolder)
graph = image.info.get("invokeai_graph", None)
if isinstance(graph, str):
return graph
@@ -150,13 +198,15 @@ def __validate_storage_folders(self) -> None:
folder.mkdir(parents=True, exist_ok=True)
def __get_cache(self, image_name: Path) -> Optional[PILImageType]:
- return None if image_name not in self.__cache else self.__cache[image_name]
+ with self.__cache_lock:
+ return None if image_name not in self.__cache else self.__cache[image_name]
def __set_cache(self, image_name: Path, image: PILImageType):
- if image_name not in self.__cache:
- self.__cache[image_name] = image
- self.__cache_ids.put(image_name) # TODO: this should refresh position for LRU cache
- if len(self.__cache) > self.__max_cache_size:
- cache_id = self.__cache_ids.get()
- if cache_id in self.__cache:
- del self.__cache[cache_id]
+ with self.__cache_lock:
+ if image_name not in self.__cache:
+ self.__cache[image_name] = image
+ self.__cache_ids.put(image_name) # TODO: this should refresh position for LRU cache
+ if len(self.__cache) > self.__max_cache_size:
+ cache_id = self.__cache_ids.get()
+ if cache_id in self.__cache:
+ del self.__cache[cache_id]
diff --git a/invokeai/app/services/image_files/image_subfolder_strategy.py b/invokeai/app/services/image_files/image_subfolder_strategy.py
new file mode 100644
index 00000000000..66c363c4f95
--- /dev/null
+++ b/invokeai/app/services/image_files/image_subfolder_strategy.py
@@ -0,0 +1,58 @@
+from abc import ABC, abstractmethod
+from datetime import datetime
+
+from invokeai.app.services.image_records.image_records_common import ImageCategory
+
+
+class ImageSubfolderStrategy(ABC):
+ """Base class for image subfolder strategies."""
+
+ @abstractmethod
+ def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str:
+ """Returns relative subfolder prefix (e.g. '2026/03/17', 'general'), or empty string for flat."""
+ pass
+
+
+class FlatStrategy(ImageSubfolderStrategy):
+ """No subfolders - all images in one directory (default behavior)."""
+
+ def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str:
+ return ""
+
+
+class DateStrategy(ImageSubfolderStrategy):
+ """Organize images by date: YYYY/MM/DD."""
+
+ def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str:
+ now = datetime.now()
+ return f"{now.year}/{now.month:02d}/{now.day:02d}"
+
+
+class TypeStrategy(ImageSubfolderStrategy):
+ """Organize images by category/type: general, intermediate, mask, control, etc."""
+
+ def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str:
+ if is_intermediate:
+ return "intermediate"
+ return image_category.value
+
+
+class HashStrategy(ImageSubfolderStrategy):
+ """Organize images by UUID prefix for filesystem performance (first 2 characters)."""
+
+ def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str:
+ return image_name[:2]
+
+
+def create_subfolder_strategy(strategy_name: str) -> ImageSubfolderStrategy:
+ """Factory function to create a subfolder strategy by name."""
+ strategies: dict[str, type[ImageSubfolderStrategy]] = {
+ "flat": FlatStrategy,
+ "date": DateStrategy,
+ "type": TypeStrategy,
+ "hash": HashStrategy,
+ }
+ cls = strategies.get(strategy_name)
+ if cls is None:
+ raise ValueError(f"Unknown subfolder strategy: {strategy_name}. Valid options: {', '.join(strategies.keys())}")
+ return cls()
diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py
index 45c0705090f..8c71dfba9e7 100644
--- a/invokeai/app/services/image_records/image_records_base.py
+++ b/invokeai/app/services/image_records/image_records_base.py
@@ -3,9 +3,16 @@
from typing import Optional
from invokeai.app.invocations.fields import MetadataField
+from invokeai.app.services.image_records.image_records_common import (
+ ImageCategory,
+ ImageNamesResult,
+ ImageRecord,
+ ImageRecordChanges,
+ ResourceOrigin,
+)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from .image_records_common import ImageCategory, ImageRecord, ImageRecordChanges, ResourceOrigin
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
class ImageRecordStorageBase(ABC):
@@ -37,12 +44,17 @@ def get_many(
self,
offset: int = 0,
limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
) -> OffsetPaginatedResults[ImageRecord]:
- """Gets a page of image records."""
+ """Gets a page of image records. When board_id is 'none', filters by user_id for per-user uncategorized images unless is_admin is True."""
pass
# TODO: The database has a nullable `deleted_at` column, currently unused.
@@ -58,13 +70,13 @@ def delete_many(self, image_names: list[str]) -> None:
pass
@abstractmethod
- def delete_intermediates(self) -> list[str]:
- """Deletes all intermediate image records, returning a list of deleted image names."""
+ def delete_intermediates(self) -> list[tuple[str, str]]:
+ """Deletes all intermediate image records, returning a list of (image_name, image_subfolder) tuples."""
pass
@abstractmethod
- def get_intermediates_count(self) -> int:
- """Gets a count of all intermediate images."""
+ def get_intermediates_count(self, user_id: Optional[str] = None) -> int:
+ """Gets a count of intermediate images. If user_id is provided, only counts that user's intermediates."""
pass
@abstractmethod
@@ -81,11 +93,57 @@ def save(
session_id: Optional[str] = None,
node_id: Optional[str] = None,
metadata: Optional[str] = None,
+ user_id: Optional[str] = None,
+ image_subfolder: str = "",
) -> datetime:
"""Saves an image record."""
pass
+ @abstractmethod
+ def get_user_id(self, image_name: str) -> Optional[str]:
+ """Gets the user_id of the image owner. Returns None if image not found."""
+ pass
+
@abstractmethod
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
"""Gets the most recent image for a board."""
pass
+
+ @abstractmethod
+ def get_image_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ """Gets ordered list of image names with metadata for optimistic updates."""
+ pass
+
+ @abstractmethod
+ def get_image_dates(
+ self,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> list[VirtualSubBoardDTO]:
+ """Gets a list of dates with image counts, grouped by DATE(created_at)."""
+ pass
+
+ @abstractmethod
+ def get_image_names_by_date(
+ self,
+ date: str,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ categories: Optional[list[ImageCategory]] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ """Gets ordered list of image names for a specific date."""
+ pass
diff --git a/invokeai/app/services/image_records/image_records_common.py b/invokeai/app/services/image_records/image_records_common.py
index af681e90e11..3d4650b77ae 100644
--- a/invokeai/app/services/image_records/image_records_common.py
+++ b/invokeai/app/services/image_records/image_records_common.py
@@ -3,7 +3,7 @@
from enum import Enum
from typing import Optional, Union
-from pydantic import Field, StrictBool, StrictStr
+from pydantic import BaseModel, Field, StrictBool, StrictStr
from invokeai.app.util.metaenum import MetaEnum
from invokeai.app.util.misc import get_iso_timestamp
@@ -58,6 +58,15 @@ class ImageCategory(str, Enum, metaclass=MetaEnum):
"""OTHER: The image is some other type of image with a specialized purpose. To be used by external nodes."""
+IMAGE_CATEGORIES: list[ImageCategory] = [ImageCategory.GENERAL]
+ASSETS_CATEGORIES: list[ImageCategory] = [
+ ImageCategory.CONTROL,
+ ImageCategory.MASK,
+ ImageCategory.USER,
+ ImageCategory.OTHER,
+]
+
+
class InvalidImageCategoryException(ValueError):
"""Raised when a provided value is not a valid ImageCategory.
@@ -106,6 +115,7 @@ def __init__(self, message="Image record not deleted"):
"updated_at",
"deleted_at",
"starred",
+ "image_subfolder",
]
]
)
@@ -147,6 +157,7 @@ class ImageRecord(BaseModelExcludeNull):
starred: bool = Field(description="Whether this image is starred.")
"""Whether this image is starred."""
has_workflow: bool = Field(description="Whether this image has a workflow.")
+ image_subfolder: str = Field(default="", description="The subfolder where the image is stored on disk.")
class ImageRecordChanges(BaseModelExcludeNull, extra="allow"):
@@ -191,6 +202,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord:
is_intermediate = image_dict.get("is_intermediate", False)
starred = image_dict.get("starred", False)
has_workflow = image_dict.get("has_workflow", False)
+ image_subfolder = image_dict.get("image_subfolder", "")
return ImageRecord(
image_name=image_name,
@@ -206,4 +218,18 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord:
is_intermediate=is_intermediate,
starred=starred,
has_workflow=has_workflow,
+ image_subfolder=image_subfolder,
)
+
+
+class ImageCollectionCounts(BaseModel):
+ starred_count: int = Field(description="The number of starred images in the collection.")
+ unstarred_count: int = Field(description="The number of unstarred images in the collection.")
+
+
+class ImageNamesResult(BaseModel):
+ """Response containing ordered image names with metadata for optimistic updates."""
+
+ image_names: list[str] = Field(description="Ordered list of image names")
+ starred_count: int = Field(description="Number of starred images (when starred_first=True)")
+ total_count: int = Field(description="Total number of images matching the query")
diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py
index ef73e79fa19..1eb3857dba6 100644
--- a/invokeai/app/services/image_records/image_records_sqlite.py
+++ b/invokeai/app/services/image_records/image_records_sqlite.py
@@ -1,16 +1,13 @@
import sqlite3
-import threading
from datetime import datetime
from typing import Optional, Union, cast
from invokeai.app.invocations.fields import MetadataField, MetadataFieldValidator
-from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
-
-from .image_records_base import ImageRecordStorageBase
-from .image_records_common import (
+from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
+from invokeai.app.services.image_records.image_records_common import (
IMAGE_DTO_COLS,
ImageCategory,
+ ImageNamesResult,
ImageRecord,
ImageRecordChanges,
ImageRecordDeleteException,
@@ -19,56 +16,66 @@
ResourceOrigin,
deserialize_image_record,
)
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
class SqliteImageRecordStorage(ImageRecordStorageBase):
- _conn: sqlite3.Connection
- _cursor: sqlite3.Cursor
- _lock: threading.RLock
-
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
- self._lock = db.lock
- self._conn = db.conn
- self._cursor = self._conn.cursor()
+ self._db = db
def get(self, image_name: str) -> ImageRecord:
- try:
- self._lock.acquire()
-
- self._cursor.execute(
- f"""--sql
- SELECT {IMAGE_DTO_COLS} FROM images
- WHERE image_name = ?;
- """,
- (image_name,),
- )
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ f"""--sql
+ SELECT {IMAGE_DTO_COLS} FROM images
+ WHERE image_name = ?;
+ """,
+ (image_name,),
+ )
- result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordNotFoundException from e
- finally:
- self._lock.release()
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ except sqlite3.Error as e:
+ raise ImageRecordNotFoundException from e
if not result:
raise ImageRecordNotFoundException
return deserialize_image_record(dict(result))
- def get_metadata(self, image_name: str) -> Optional[MetadataField]:
- try:
- self._lock.acquire()
-
- self._cursor.execute(
+ def get_user_id(self, image_name: str) -> Optional[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT metadata FROM images
+ SELECT user_id FROM images
WHERE image_name = ?;
""",
(image_name,),
)
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+ if not result:
+ return None
+ return cast(Optional[str], dict(result).get("user_id"))
- result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
+ def get_metadata(self, image_name: str) -> Optional[MetadataField]:
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ SELECT metadata FROM images
+ WHERE image_name = ?;
+ """,
+ (image_name,),
+ )
+
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+
+ except sqlite3.Error as e:
+ raise ImageRecordNotFoundException from e
if not result:
raise ImageRecordNotFoundException
@@ -76,82 +83,76 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]:
as_dict = dict(result)
metadata_raw = cast(Optional[str], as_dict.get("metadata", None))
return MetadataFieldValidator.validate_json(metadata_raw) if metadata_raw is not None else None
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordNotFoundException from e
- finally:
- self._lock.release()
def update(
self,
image_name: str,
changes: ImageRecordChanges,
) -> None:
- try:
- self._lock.acquire()
- # Change the category of the image
- if changes.image_category is not None:
- self._cursor.execute(
- """--sql
- UPDATE images
- SET image_category = ?
- WHERE image_name = ?;
- """,
- (changes.image_category, image_name),
- )
+ with self._db.transaction() as cursor:
+ try:
+ # Change the category of the image
+ if changes.image_category is not None:
+ cursor.execute(
+ """--sql
+ UPDATE images
+ SET image_category = ?
+ WHERE image_name = ?;
+ """,
+ (changes.image_category, image_name),
+ )
- # Change the session associated with the image
- if changes.session_id is not None:
- self._cursor.execute(
- """--sql
- UPDATE images
- SET session_id = ?
- WHERE image_name = ?;
- """,
- (changes.session_id, image_name),
- )
+ # Change the session associated with the image
+ if changes.session_id is not None:
+ cursor.execute(
+ """--sql
+ UPDATE images
+ SET session_id = ?
+ WHERE image_name = ?;
+ """,
+ (changes.session_id, image_name),
+ )
- # Change the image's `is_intermediate`` flag
- if changes.is_intermediate is not None:
- self._cursor.execute(
- """--sql
- UPDATE images
- SET is_intermediate = ?
- WHERE image_name = ?;
- """,
- (changes.is_intermediate, image_name),
- )
+ # Change the image's `is_intermediate`` flag
+ if changes.is_intermediate is not None:
+ cursor.execute(
+ """--sql
+ UPDATE images
+ SET is_intermediate = ?
+ WHERE image_name = ?;
+ """,
+ (changes.is_intermediate, image_name),
+ )
- # Change the image's `starred`` state
- if changes.starred is not None:
- self._cursor.execute(
- """--sql
- UPDATE images
- SET starred = ?
- WHERE image_name = ?;
- """,
- (changes.starred, image_name),
- )
+ # Change the image's `starred`` state
+ if changes.starred is not None:
+ cursor.execute(
+ """--sql
+ UPDATE images
+ SET starred = ?
+ WHERE image_name = ?;
+ """,
+ (changes.starred, image_name),
+ )
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordSaveException from e
- finally:
- self._lock.release()
+ except sqlite3.Error as e:
+ raise ImageRecordSaveException from e
def get_many(
self,
offset: int = 0,
limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
) -> OffsetPaginatedResults[ImageRecord]:
- try:
- self._lock.acquire()
-
+ with self._db.transaction() as cursor:
# Manually build two queries - one for the count, one for the records
count_query = """--sql
SELECT COUNT(*)
@@ -202,15 +203,38 @@ def get_many(
query_conditions += """--sql
AND board_images.board_id IS NULL
"""
+ # For uncategorized images, filter by user_id to ensure per-user isolation
+ # Admin users can see all uncategorized images from all users
+ if user_id is not None and not is_admin:
+ query_conditions += """--sql
+ AND images.user_id = ?
+ """
+ query_params.append(user_id)
elif board_id is not None:
query_conditions += """--sql
AND board_images.board_id = ?
"""
query_params.append(board_id)
- query_pagination = """--sql
- ORDER BY images.starred DESC, images.created_at DESC LIMIT ? OFFSET ?
- """
+ # Search term condition
+ if search_term:
+ query_conditions += """--sql
+ AND (
+ images.metadata LIKE ?
+ OR images.created_at LIKE ?
+ )
+ """
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ if starred_first:
+ query_pagination = f"""--sql
+ ORDER BY images.starred DESC, images.created_at {order_dir.value} LIMIT ? OFFSET ?
+ """
+ else:
+ query_pagination = f"""--sql
+ ORDER BY images.created_at {order_dir.value} LIMIT ? OFFSET ?
+ """
# Final images query with pagination
images_query += query_conditions + query_pagination + ";"
@@ -220,101 +244,81 @@ def get_many(
images_params.extend([limit, offset])
# Build the list of images, deserializing each row
- self._cursor.execute(images_query, images_params)
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
+ cursor.execute(images_query, images_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+
images = [deserialize_image_record(dict(r)) for r in result]
# Set up and execute the count query, without pagination
count_query += query_conditions + ";"
count_params = query_params.copy()
- self._cursor.execute(count_query, count_params)
- count = cast(int, self._cursor.fetchone()[0])
- except sqlite3.Error as e:
- self._conn.rollback()
- raise e
- finally:
- self._lock.release()
+ cursor.execute(count_query, count_params)
+ count = cast(int, cursor.fetchone()[0])
return OffsetPaginatedResults(items=images, offset=offset, limit=limit, total=count)
def delete(self, image_name: str) -> None:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- DELETE FROM images
- WHERE image_name = ?;
- """,
- (image_name,),
- )
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordDeleteException from e
- finally:
- self._lock.release()
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ DELETE FROM images
+ WHERE image_name = ?;
+ """,
+ (image_name,),
+ )
+ except sqlite3.Error as e:
+ raise ImageRecordDeleteException from e
def delete_many(self, image_names: list[str]) -> None:
- try:
- placeholders = ",".join("?" for _ in image_names)
-
- self._lock.acquire()
-
- # Construct the SQLite query with the placeholders
- query = f"DELETE FROM images WHERE image_name IN ({placeholders})"
-
- # Execute the query with the list of IDs as parameters
- self._cursor.execute(query, image_names)
-
- self._conn.commit()
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordDeleteException from e
- finally:
- self._lock.release()
-
- def get_intermediates_count(self) -> int:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- SELECT COUNT(*) FROM images
- WHERE is_intermediate = TRUE;
- """
- )
- count = cast(int, self._cursor.fetchone()[0])
- self._conn.commit()
- return count
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordDeleteException from e
- finally:
- self._lock.release()
-
- def delete_intermediates(self) -> list[str]:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- SELECT image_name FROM images
- WHERE is_intermediate = TRUE;
- """
- )
- result = cast(list[sqlite3.Row], self._cursor.fetchall())
- image_names = [r[0] for r in result]
- self._cursor.execute(
- """--sql
- DELETE FROM images
- WHERE is_intermediate = TRUE;
- """
- )
- self._conn.commit()
- return image_names
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordDeleteException from e
- finally:
- self._lock.release()
+ with self._db.transaction() as cursor:
+ try:
+ placeholders = ",".join("?" for _ in image_names)
+
+ # Construct the SQLite query with the placeholders
+ query = f"DELETE FROM images WHERE image_name IN ({placeholders})"
+
+ # Execute the query with the list of IDs as parameters
+ cursor.execute(query, image_names)
+
+ except sqlite3.Error as e:
+ raise ImageRecordDeleteException from e
+
+ def get_intermediates_count(self, user_id: Optional[str] = None) -> int:
+ with self._db.transaction() as cursor:
+ query = "SELECT COUNT(*) FROM images WHERE is_intermediate = TRUE"
+ params: list[str] = []
+ if user_id is not None:
+ query += " AND user_id = ?"
+ params.append(user_id)
+ cursor.execute(query, params)
+ count = cast(int, cursor.fetchone()[0])
+ return count
+
+ def delete_intermediates(self) -> list[tuple[str, str]]:
+ """Deletes all intermediate image records.
+
+ Returns a list of (image_name, image_subfolder) tuples for file cleanup.
+ """
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ SELECT image_name, image_subfolder FROM images
+ WHERE is_intermediate = TRUE;
+ """
+ )
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ image_name_subfolder_pairs = [(r[0], r[1]) for r in result]
+ cursor.execute(
+ """--sql
+ DELETE FROM images
+ WHERE is_intermediate = TRUE;
+ """
+ )
+ except sqlite3.Error as e:
+ raise ImageRecordDeleteException from e
+ return image_name_subfolder_pairs
def save(
self,
@@ -329,79 +333,319 @@ def save(
session_id: Optional[str] = None,
node_id: Optional[str] = None,
metadata: Optional[str] = None,
+ user_id: Optional[str] = None,
+ image_subfolder: str = "",
) -> datetime:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- INSERT OR IGNORE INTO images (
- image_name,
- image_origin,
- image_category,
- width,
- height,
- node_id,
- session_id,
- metadata,
- is_intermediate,
- starred,
- has_workflow
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
- """,
- (
- image_name,
- image_origin.value,
- image_category.value,
- width,
- height,
- node_id,
- session_id,
- metadata,
- is_intermediate,
- starred,
- has_workflow,
- ),
- )
- self._conn.commit()
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO images (
+ image_name,
+ image_origin,
+ image_category,
+ width,
+ height,
+ node_id,
+ session_id,
+ metadata,
+ is_intermediate,
+ starred,
+ has_workflow,
+ user_id,
+ image_subfolder
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
+ """,
+ (
+ image_name,
+ image_origin.value,
+ image_category.value,
+ width,
+ height,
+ node_id,
+ session_id,
+ metadata,
+ is_intermediate,
+ starred,
+ has_workflow,
+ user_id or "system",
+ image_subfolder,
+ ),
+ )
- self._cursor.execute(
- """--sql
- SELECT created_at
- FROM images
- WHERE image_name = ?;
- """,
- (image_name,),
- )
+ cursor.execute(
+ """--sql
+ SELECT created_at
+ FROM images
+ WHERE image_name = ?;
+ """,
+ (image_name,),
+ )
- created_at = datetime.fromisoformat(self._cursor.fetchone()[0])
+ created_at = datetime.fromisoformat(cursor.fetchone()[0])
- return created_at
- except sqlite3.Error as e:
- self._conn.rollback()
- raise ImageRecordSaveException from e
- finally:
- self._lock.release()
+ except sqlite3.Error as e:
+ raise ImageRecordSaveException from e
+ return created_at
def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]:
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
SELECT images.*
FROM images
JOIN board_images ON images.image_name = board_images.image_name
WHERE board_images.board_id = ?
+ AND images.is_intermediate = FALSE
ORDER BY images.starred DESC, images.created_at DESC
LIMIT 1;
""",
(board_id,),
)
- result = cast(Optional[sqlite3.Row], self._cursor.fetchone())
- finally:
- self._lock.release()
+ result = cast(Optional[sqlite3.Row], cursor.fetchone())
+
if result is None:
return None
return deserialize_image_record(dict(result))
+
+ def get_image_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ with self._db.transaction() as cursor:
+ # Build query conditions (reused for both starred count and image names queries)
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ if image_origin is not None:
+ query_conditions += """--sql
+ AND images.image_origin = ?
+ """
+ query_params.append(image_origin.value)
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f"""--sql
+ AND images.image_category IN ( {placeholders} )
+ """
+ for c in category_strings:
+ query_params.append(c)
+
+ if is_intermediate is not None:
+ query_conditions += """--sql
+ AND images.is_intermediate = ?
+ """
+ query_params.append(is_intermediate)
+
+ if board_id == "none":
+ query_conditions += """--sql
+ AND board_images.board_id IS NULL
+ """
+ # For uncategorized images, filter by user_id to ensure per-user isolation
+ # Admin users can see all uncategorized images from all users
+ if user_id is not None and not is_admin:
+ query_conditions += """--sql
+ AND images.user_id = ?
+ """
+ query_params.append(user_id)
+ elif board_id is not None:
+ query_conditions += """--sql
+ AND board_images.board_id = ?
+ """
+ query_params.append(board_id)
+
+ if search_term:
+ query_conditions += """--sql
+ AND (
+ images.metadata LIKE ?
+ OR images.created_at LIKE ?
+ )
+ """
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ # Get starred count if starred_first is enabled
+ starred_count = 0
+ if starred_first:
+ starred_count_query = f"""--sql
+ SELECT COUNT(*)
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE images.starred = TRUE AND (1=1{query_conditions})
+ """
+ cursor.execute(starred_count_query, query_params)
+ starred_count = cast(int, cursor.fetchone()[0])
+
+ # Get all image names with proper ordering
+ if starred_first:
+ names_query = f"""--sql
+ SELECT images.image_name
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1{query_conditions}
+ ORDER BY images.starred DESC, images.created_at {order_dir.value}
+ """
+ else:
+ names_query = f"""--sql
+ SELECT images.image_name
+ FROM images
+ LEFT JOIN board_images ON board_images.image_name = images.image_name
+ WHERE 1=1{query_conditions}
+ ORDER BY images.created_at {order_dir.value}
+ """
+
+ cursor.execute(names_query, query_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ image_names = [row[0] for row in result]
+
+ return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))
+
+ def get_image_dates(
+ self,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> list[VirtualSubBoardDTO]:
+ with self._db.transaction() as cursor:
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ # Only non-intermediate images
+ query_conditions += """--sql
+ AND images.is_intermediate = 0
+ """
+
+ # User isolation for non-admin users
+ if user_id is not None and not is_admin:
+ query_conditions += """--sql
+ AND images.user_id = ?
+ """
+ query_params.append(user_id)
+
+ query = f"""--sql
+ SELECT
+ DATE(images.created_at) as date,
+ SUM(CASE WHEN images.image_category = 'general' THEN 1 ELSE 0 END) as image_count,
+ SUM(CASE WHEN images.image_category != 'general' THEN 1 ELSE 0 END) as asset_count,
+ (
+ SELECT i2.image_name FROM images i2
+ WHERE DATE(i2.created_at) = DATE(images.created_at)
+ AND i2.is_intermediate = 0
+ ORDER BY i2.created_at DESC LIMIT 1
+ ) as cover_image_name
+ FROM images
+ WHERE 1=1
+ {query_conditions}
+ GROUP BY DATE(images.created_at)
+ ORDER BY date DESC;
+ """
+
+ cursor.execute(query, query_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+
+ return [
+ VirtualSubBoardDTO(
+ virtual_board_id=f"by_date:{dict(row)['date']}",
+ board_name=dict(row)["date"],
+ date=dict(row)["date"],
+ image_count=dict(row)["image_count"],
+ asset_count=dict(row)["asset_count"],
+ cover_image_name=dict(row)["cover_image_name"],
+ )
+ for row in result
+ ]
+
+ def get_image_names_by_date(
+ self,
+ date: str,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ categories: Optional[list[ImageCategory]] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ with self._db.transaction() as cursor:
+ query_conditions = ""
+ query_params: list[Union[int, str, bool]] = []
+
+ # Filter by date
+ query_conditions += """--sql
+ AND DATE(images.created_at) = ?
+ """
+ query_params.append(date)
+
+ # Only non-intermediate images
+ query_conditions += """--sql
+ AND images.is_intermediate = 0
+ """
+
+ if categories is not None:
+ category_strings = [c.value for c in set(categories)]
+ placeholders = ",".join("?" * len(category_strings))
+ query_conditions += f"""--sql
+ AND images.image_category IN ( {placeholders} )
+ """
+ for c in category_strings:
+ query_params.append(c)
+
+ # User isolation for non-admin users
+ if user_id is not None and not is_admin:
+ query_conditions += """--sql
+ AND images.user_id = ?
+ """
+ query_params.append(user_id)
+
+ if search_term:
+ query_conditions += """--sql
+ AND (
+ images.metadata LIKE ?
+ OR images.created_at LIKE ?
+ )
+ """
+ query_params.append(f"%{search_term.lower()}%")
+ query_params.append(f"%{search_term.lower()}%")
+
+ # Get starred count if starred_first is enabled
+ starred_count = 0
+ if starred_first:
+ starred_count_query = f"""--sql
+ SELECT COUNT(*)
+ FROM images
+ WHERE images.starred = TRUE AND (1=1{query_conditions})
+ """
+ cursor.execute(starred_count_query, query_params)
+ starred_count = cast(int, cursor.fetchone()[0])
+
+ # Get all image names with proper ordering
+ if starred_first:
+ names_query = f"""--sql
+ SELECT images.image_name
+ FROM images
+ WHERE 1=1{query_conditions}
+ ORDER BY images.starred DESC, images.created_at {order_dir.value}
+ """
+ else:
+ names_query = f"""--sql
+ SELECT images.image_name
+ FROM images
+ WHERE 1=1{query_conditions}
+ ORDER BY images.created_at {order_dir.value}
+ """
+
+ cursor.execute(names_query, query_params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ image_names = [row[0] for row in result]
+
+ return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))
diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py
index 9175fc4809e..aebbead2f35 100644
--- a/invokeai/app/services/images/images_base.py
+++ b/invokeai/app/services/images/images_base.py
@@ -6,12 +6,14 @@
from invokeai.app.invocations.fields import MetadataField
from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
+ ImageNamesResult,
ImageRecord,
ImageRecordChanges,
ResourceOrigin,
)
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class ImageServiceABC(ABC):
@@ -53,6 +55,7 @@ def create(
metadata: Optional[str] = None,
workflow: Optional[str] = None,
graph: Optional[str] = None,
+ user_id: Optional[str] = None,
) -> ImageDTO:
"""Creates an image, storing the file and its metadata."""
pass
@@ -116,12 +119,17 @@ def get_many(
self,
offset: int = 0,
limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
) -> OffsetPaginatedResults[ImageDTO]:
- """Gets a paginated list of image DTOs."""
+ """Gets a paginated list of image DTOs with starred images first when starred_first=True."""
pass
@abstractmethod
@@ -135,11 +143,27 @@ def delete_intermediates(self) -> int:
pass
@abstractmethod
- def get_intermediates_count(self) -> int:
- """Gets the number of intermediate images."""
+ def get_intermediates_count(self, user_id: Optional[str] = None) -> int:
+ """Gets the number of intermediate images. If user_id is provided, only counts that user's intermediates."""
pass
@abstractmethod
def delete_images_on_board(self, board_id: str):
"""Deletes all images on a board."""
pass
+
+ @abstractmethod
+ def get_image_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ """Gets ordered list of image names with metadata for optimistic updates."""
+ pass
diff --git a/invokeai/app/services/images/images_common.py b/invokeai/app/services/images/images_common.py
index 0464244b944..311f6e556d2 100644
--- a/invokeai/app/services/images/images_common.py
+++ b/invokeai/app/services/images/images_common.py
@@ -1,6 +1,6 @@
from typing import Optional
-from pydantic import Field
+from pydantic import BaseModel, Field
from invokeai.app.services.image_records.image_records_common import ImageRecord
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
@@ -39,3 +39,27 @@ def image_record_to_dto(
thumbnail_url=thumbnail_url,
board_id=board_id,
)
+
+
+class ResultWithAffectedBoards(BaseModel):
+ affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation")
+
+
+class DeleteImagesResult(ResultWithAffectedBoards):
+ deleted_images: list[str] = Field(description="The names of the images that were deleted")
+
+
+class StarredImagesResult(ResultWithAffectedBoards):
+ starred_images: list[str] = Field(description="The names of the images that were starred")
+
+
+class UnstarredImagesResult(ResultWithAffectedBoards):
+ unstarred_images: list[str] = Field(description="The names of the images that were unstarred")
+
+
+class AddImagesToBoardResult(ResultWithAffectedBoards):
+ added_images: list[str] = Field(description="The image names that were added to the board")
+
+
+class RemoveImagesFromBoardResult(ResultWithAffectedBoards):
+ removed_images: list[str] = Field(description="The image names that were removed from their board")
diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py
index 1206526bd58..4a190f37edc 100644
--- a/invokeai/app/services/images/images_default.py
+++ b/invokeai/app/services/images/images_default.py
@@ -3,16 +3,15 @@
from PIL.Image import Image as PILImageType
from invokeai.app.invocations.fields import MetadataField
-from invokeai.app.services.invoker import Invoker
-from invokeai.app.services.shared.pagination import OffsetPaginatedResults
-
-from ..image_files.image_files_common import (
+from invokeai.app.services.image_files.image_files_common import (
ImageFileDeleteException,
ImageFileNotFoundException,
ImageFileSaveException,
)
-from ..image_records.image_records_common import (
+from invokeai.app.services.image_files.image_subfolder_strategy import create_subfolder_strategy
+from invokeai.app.services.image_records.image_records_common import (
ImageCategory,
+ ImageNamesResult,
ImageRecord,
ImageRecordChanges,
ImageRecordDeleteException,
@@ -22,8 +21,11 @@
InvalidOriginException,
ResourceOrigin,
)
-from .images_base import ImageServiceABC
-from .images_common import ImageDTO, image_record_to_dto
+from invokeai.app.services.images.images_base import ImageServiceABC
+from invokeai.app.services.images.images_common import ImageDTO, image_record_to_dto
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.pagination import OffsetPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class ImageService(ImageServiceABC):
@@ -44,6 +46,7 @@ def create(
metadata: Optional[str] = None,
workflow: Optional[str] = None,
graph: Optional[str] = None,
+ user_id: Optional[str] = None,
) -> ImageDTO:
if image_origin not in ResourceOrigin:
raise InvalidOriginException
@@ -53,6 +56,11 @@ def create(
image_name = self.__invoker.services.names.create_image_name()
+ # Compute subfolder based on configured strategy
+ strategy_name = self.__invoker.services.configuration.image_subfolder_strategy
+ strategy = create_subfolder_strategy(strategy_name)
+ image_subfolder = strategy.get_subfolder(image_name, image_category, is_intermediate or False)
+
(width, height) = image.size
try:
@@ -71,11 +79,23 @@ def create(
node_id=node_id,
metadata=metadata,
session_id=session_id,
+ user_id=user_id,
+ image_subfolder=image_subfolder,
)
if board_id is not None:
- self.__invoker.services.board_image_records.add_image_to_board(board_id=board_id, image_name=image_name)
+ try:
+ self.__invoker.services.board_image_records.add_image_to_board(
+ board_id=board_id, image_name=image_name
+ )
+ except Exception as e:
+ self.__invoker.services.logger.warning(f"Failed to add image to board {board_id}: {str(e)}")
self.__invoker.services.image_files.save(
- image_name=image_name, image=image, metadata=metadata, workflow=workflow, graph=graph
+ image_name=image_name,
+ image=image,
+ metadata=metadata,
+ workflow=workflow,
+ graph=graph,
+ image_subfolder=image_subfolder,
)
image_dto = self.get_dto(image_name)
@@ -110,7 +130,8 @@ def update(
def get_pil_image(self, image_name: str) -> PILImageType:
try:
- return self.__invoker.services.image_files.get(image_name)
+ record = self.__invoker.services.image_records.get(image_name)
+ return self.__invoker.services.image_files.get(image_name, image_subfolder=record.image_subfolder)
except ImageFileNotFoundException:
self.__invoker.services.logger.error("Failed to get image file")
raise
@@ -159,7 +180,8 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]:
def get_workflow(self, image_name: str) -> Optional[str]:
try:
- return self.__invoker.services.image_files.get_workflow(image_name)
+ record = self.__invoker.services.image_records.get(image_name)
+ return self.__invoker.services.image_files.get_workflow(image_name, image_subfolder=record.image_subfolder)
except ImageFileNotFoundException:
self.__invoker.services.logger.error("Image file not found")
raise
@@ -169,7 +191,8 @@ def get_workflow(self, image_name: str) -> Optional[str]:
def get_graph(self, image_name: str) -> Optional[str]:
try:
- return self.__invoker.services.image_files.get_graph(image_name)
+ record = self.__invoker.services.image_records.get(image_name)
+ return self.__invoker.services.image_files.get_graph(image_name, image_subfolder=record.image_subfolder)
except ImageFileNotFoundException:
self.__invoker.services.logger.error("Image file not found")
raise
@@ -179,7 +202,12 @@ def get_graph(self, image_name: str) -> Optional[str]:
def get_path(self, image_name: str, thumbnail: bool = False) -> str:
try:
- return str(self.__invoker.services.image_files.get_path(image_name, thumbnail))
+ record = self.__invoker.services.image_records.get(image_name)
+ return str(
+ self.__invoker.services.image_files.get_path(
+ image_name, thumbnail, image_subfolder=record.image_subfolder
+ )
+ )
except Exception as e:
self.__invoker.services.logger.error("Problem getting image path")
raise e
@@ -202,19 +230,29 @@ def get_many(
self,
offset: int = 0,
limit: int = 10,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
image_origin: Optional[ResourceOrigin] = None,
categories: Optional[list[ImageCategory]] = None,
is_intermediate: Optional[bool] = None,
board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
) -> OffsetPaginatedResults[ImageDTO]:
try:
results = self.__invoker.services.image_records.get_many(
offset,
limit,
+ starred_first,
+ order_dir,
image_origin,
categories,
is_intermediate,
board_id,
+ search_term,
+ user_id,
+ is_admin,
)
image_dtos = [
@@ -239,7 +277,8 @@ def get_many(
def delete(self, image_name: str):
try:
- self.__invoker.services.image_files.delete(image_name)
+ record = self.__invoker.services.image_records.get(image_name)
+ self.__invoker.services.image_files.delete(image_name, image_subfolder=record.image_subfolder)
self.__invoker.services.image_records.delete(image_name)
self._on_deleted(image_name)
except ImageRecordDeleteException:
@@ -254,9 +293,17 @@ def delete(self, image_name: str):
def delete_images_on_board(self, board_id: str):
try:
- image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id)
+ image_names = self.__invoker.services.board_image_records.get_all_board_image_names_for_board(
+ board_id,
+ categories=None,
+ is_intermediate=None,
+ )
for image_name in image_names:
- self.__invoker.services.image_files.delete(image_name)
+ try:
+ record = self.__invoker.services.image_records.get(image_name)
+ self.__invoker.services.image_files.delete(image_name, image_subfolder=record.image_subfolder)
+ except Exception:
+ pass
self.__invoker.services.image_records.delete_many(image_names)
for image_name in image_names:
self._on_deleted(image_name)
@@ -267,15 +314,15 @@ def delete_images_on_board(self, board_id: str):
self.__invoker.services.logger.error("Failed to delete image files")
raise
except Exception as e:
- self.__invoker.services.logger.error("Problem deleting image records and files")
+ self.__invoker.services.logger.error(f"Problem deleting image records and files: {str(e)}")
raise e
def delete_intermediates(self) -> int:
try:
- image_names = self.__invoker.services.image_records.delete_intermediates()
- count = len(image_names)
- for image_name in image_names:
- self.__invoker.services.image_files.delete(image_name)
+ image_name_subfolder_pairs = self.__invoker.services.image_records.delete_intermediates()
+ count = len(image_name_subfolder_pairs)
+ for image_name, image_subfolder in image_name_subfolder_pairs:
+ self.__invoker.services.image_files.delete(image_name, image_subfolder=image_subfolder)
self._on_deleted(image_name)
return count
except ImageRecordDeleteException:
@@ -288,9 +335,37 @@ def delete_intermediates(self) -> int:
self.__invoker.services.logger.error("Problem deleting image records and files")
raise e
- def get_intermediates_count(self) -> int:
+ def get_intermediates_count(self, user_id: Optional[str] = None) -> int:
try:
- return self.__invoker.services.image_records.get_intermediates_count()
+ return self.__invoker.services.image_records.get_intermediates_count(user_id=user_id)
except Exception as e:
self.__invoker.services.logger.error("Problem getting intermediates count")
raise e
+
+ def get_image_names(
+ self,
+ starred_first: bool = True,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ image_origin: Optional[ResourceOrigin] = None,
+ categories: Optional[list[ImageCategory]] = None,
+ is_intermediate: Optional[bool] = None,
+ board_id: Optional[str] = None,
+ search_term: Optional[str] = None,
+ user_id: Optional[str] = None,
+ is_admin: bool = False,
+ ) -> ImageNamesResult:
+ try:
+ return self.__invoker.services.image_records.get_image_names(
+ starred_first=starred_first,
+ order_dir=order_dir,
+ image_origin=image_origin,
+ categories=categories,
+ is_intermediate=is_intermediate,
+ board_id=board_id,
+ search_term=search_term,
+ user_id=user_id,
+ is_admin=is_admin,
+ )
+ except Exception as e:
+ self.__invoker.services.logger.error("Problem getting image names")
+ raise e
diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py
index f4fce6098f3..2c95f87b41d 100644
--- a/invokeai/app/services/invocation_services.py
+++ b/invokeai/app/services/invocation_services.py
@@ -4,35 +4,44 @@
from typing import TYPE_CHECKING
from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase
+from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase
+from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase
if TYPE_CHECKING:
from logging import Logger
import torch
+ from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase
+ from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC
+ from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase
+ from invokeai.app.services.boards.boards_base import BoardServiceABC
+ from invokeai.app.services.bulk_download.bulk_download_base import BulkDownloadBase
+ from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC
+ 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
+ from invokeai.app.services.invocation_cache.invocation_cache_base import InvocationCacheBase
+ from invokeai.app.services.invocation_stats.invocation_stats_base import InvocationStatsServiceBase
+ from invokeai.app.services.model_images.model_images_base import ModelImageFileStorageBase
+ from invokeai.app.services.model_manager.model_manager_base import ModelManagerServiceBase
+ from invokeai.app.services.model_relationship_records.model_relationship_records_base import (
+ ModelRelationshipRecordStorageBase,
+ )
+ from invokeai.app.services.model_relationships.model_relationships_base import ModelRelationshipsServiceABC
+ from invokeai.app.services.names.names_base import NameServiceBase
+ from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase
+ from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
+ from invokeai.app.services.urls.urls_base import UrlServiceBase
+ from invokeai.app.services.users.users_base import UserServiceBase
+ from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
+ from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
- from .board_image_records.board_image_records_base import BoardImageRecordStorageBase
- from .board_images.board_images_base import BoardImagesServiceABC
- from .board_records.board_records_base import BoardRecordStorageBase
- from .boards.boards_base import BoardServiceABC
- from .bulk_download.bulk_download_base import BulkDownloadBase
- from .config import InvokeAIAppConfig
- from .download import DownloadQueueServiceBase
- from .events.events_base import EventServiceBase
- from .image_files.image_files_base import ImageFileStorageBase
- from .image_records.image_records_base import ImageRecordStorageBase
- from .images.images_base import ImageServiceABC
- from .invocation_cache.invocation_cache_base import InvocationCacheBase
- from .invocation_stats.invocation_stats_base import InvocationStatsServiceBase
- from .model_images.model_images_base import ModelImageFileStorageBase
- from .model_manager.model_manager_base import ModelManagerServiceBase
- from .names.names_base import NameServiceBase
- from .session_processor.session_processor_base import SessionProcessorBase
- from .session_queue.session_queue_base import SessionQueueBase
- from .urls.urls_base import UrlServiceBase
- from .workflow_records.workflow_records_base import WorkflowRecordsStorageBase
-
class InvocationServices:
"""Services that can be used by invocations"""
@@ -52,7 +61,10 @@ def __init__(
logger: "Logger",
model_images: "ModelImageFileStorageBase",
model_manager: "ModelManagerServiceBase",
+ model_relationships: "ModelRelationshipsServiceABC",
+ model_relationship_records: "ModelRelationshipRecordStorageBase",
download_queue: "DownloadQueueServiceBase",
+ external_generation: "ExternalGenerationServiceBase",
performance_statistics: "InvocationStatsServiceBase",
session_queue: "SessionQueueBase",
session_processor: "SessionProcessorBase",
@@ -62,6 +74,11 @@ def __init__(
workflow_records: "WorkflowRecordsStorageBase",
tensors: "ObjectSerializerBase[torch.Tensor]",
conditioning: "ObjectSerializerBase[ConditioningFieldData]",
+ style_preset_records: "StylePresetRecordsStorageBase",
+ style_preset_image_files: "StylePresetImageFileStorageBase",
+ workflow_thumbnails: "WorkflowThumbnailServiceBase",
+ client_state_persistence: "ClientStatePersistenceABC",
+ users: "UserServiceBase",
):
self.board_images = board_images
self.board_image_records = board_image_records
@@ -76,7 +93,10 @@ def __init__(
self.logger = logger
self.model_images = model_images
self.model_manager = model_manager
+ 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
@@ -86,3 +106,8 @@ def __init__(
self.workflow_records = workflow_records
self.tensors = tensors
self.conditioning = conditioning
+ self.style_preset_records = style_preset_records
+ self.style_preset_image_files = style_preset_image_files
+ self.workflow_thumbnails = workflow_thumbnails
+ self.client_state_persistence = client_state_persistence
+ self.users = users
diff --git a/invokeai/app/services/invocation_stats/invocation_stats_base.py b/invokeai/app/services/invocation_stats/invocation_stats_base.py
index 3266d985fef..1ada23c79b3 100644
--- a/invokeai/app/services/invocation_stats/invocation_stats_base.py
+++ b/invokeai/app/services/invocation_stats/invocation_stats_base.py
@@ -60,7 +60,7 @@ def collect_stats(
pass
@abstractmethod
- def reset_stats(self):
+ def reset_stats(self, graph_execution_state_id: str) -> None:
"""Reset all stored statistics."""
pass
diff --git a/invokeai/app/services/invocation_stats/invocation_stats_common.py b/invokeai/app/services/invocation_stats/invocation_stats_common.py
index f4c906a58f7..4fec6d7bcf0 100644
--- a/invokeai/app/services/invocation_stats/invocation_stats_common.py
+++ b/invokeai/app/services/invocation_stats/invocation_stats_common.py
@@ -14,7 +14,7 @@ class NodeExecutionStatsSummary:
node_type: str
num_calls: int
time_used_seconds: float
- peak_vram_gb: float
+ delta_vram_gb: float
@dataclass
@@ -58,10 +58,10 @@ class InvocationStatsSummary:
def __str__(self) -> str:
_str = ""
_str = f"Graph stats: {self.graph_stats.graph_execution_state_id}\n"
- _str += f"{'Node':>30} {'Calls':>7} {'Seconds':>9} {'VRAM Used':>10}\n"
+ _str += f"{'Node':>30} {'Calls':>7} {'Seconds':>9} {'VRAM Change':+>10}\n"
for summary in self.node_stats:
- _str += f"{summary.node_type:>30} {summary.num_calls:>7} {summary.time_used_seconds:>8.3f}s {summary.peak_vram_gb:>9.3f}G\n"
+ _str += f"{summary.node_type:>30} {summary.num_calls:>7} {summary.time_used_seconds:>8.3f}s {summary.delta_vram_gb:+10.3f}G\n"
_str += f"TOTAL GRAPH EXECUTION TIME: {self.graph_stats.execution_time_seconds:7.3f}s\n"
@@ -100,7 +100,7 @@ class NodeExecutionStats:
start_ram_gb: float # GB
end_ram_gb: float # GB
- peak_vram_gb: float # GB
+ delta_vram_gb: float # GB
def total_time(self) -> float:
return self.end_time - self.start_time
@@ -174,9 +174,9 @@ def get_node_stats_summaries(self) -> list[NodeExecutionStatsSummary]:
for node_type, node_type_stats_list in node_stats_by_type.items():
num_calls = len(node_type_stats_list)
time_used = sum([n.total_time() for n in node_type_stats_list])
- peak_vram = max([n.peak_vram_gb for n in node_type_stats_list])
+ delta_vram = max([n.delta_vram_gb for n in node_type_stats_list])
summary = NodeExecutionStatsSummary(
- node_type=node_type, num_calls=num_calls, time_used_seconds=time_used, peak_vram_gb=peak_vram
+ node_type=node_type, num_calls=num_calls, time_used_seconds=time_used, delta_vram_gb=delta_vram
)
summaries.append(summary)
diff --git a/invokeai/app/services/invocation_stats/invocation_stats_default.py b/invokeai/app/services/invocation_stats/invocation_stats_default.py
index 5a41f1f5d6b..9245d372d2e 100644
--- a/invokeai/app/services/invocation_stats/invocation_stats_default.py
+++ b/invokeai/app/services/invocation_stats/invocation_stats_default.py
@@ -9,11 +9,8 @@
import invokeai.backend.util.logging as logger
from invokeai.app.invocations.baseinvocation import BaseInvocation
-from invokeai.app.services.invoker import Invoker
-from invokeai.backend.model_manager.load.model_cache import CacheStats
-
-from .invocation_stats_base import InvocationStatsServiceBase
-from .invocation_stats_common import (
+from invokeai.app.services.invocation_stats.invocation_stats_base import InvocationStatsServiceBase
+from invokeai.app.services.invocation_stats.invocation_stats_common import (
GESStatsNotFoundError,
GraphExecutionStats,
GraphExecutionStatsSummary,
@@ -22,6 +19,8 @@
NodeExecutionStats,
NodeExecutionStatsSummary,
)
+from invokeai.app.services.invoker import Invoker
+from invokeai.backend.model_manager.load.model_cache.cache_stats import CacheStats
# Size of 1GB in bytes.
GB = 2**30
@@ -53,8 +52,9 @@ def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: st
# Record state before the invocation.
start_time = time.time()
start_ram = psutil.Process().memory_info().rss
- if torch.cuda.is_available():
- torch.cuda.reset_peak_memory_stats()
+
+ # Remember current VRAM usage
+ vram_in_use = torch.cuda.memory_allocated() if torch.cuda.is_available() else 0.0
assert services.model_manager.load is not None
services.model_manager.load.ram_cache.stats = self._cache_stats[graph_execution_state_id]
@@ -63,25 +63,29 @@ def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: st
# Let the invocation run.
yield None
finally:
- # Record state after the invocation.
+ # Record delta VRAM
+ delta_vram_gb = ((torch.cuda.memory_allocated() - vram_in_use) / GB) if torch.cuda.is_available() else 0.0
+
node_stats = NodeExecutionStats(
invocation_type=invocation.get_type(),
start_time=start_time,
end_time=time.time(),
start_ram_gb=start_ram / GB,
end_ram_gb=psutil.Process().memory_info().rss / GB,
- peak_vram_gb=torch.cuda.max_memory_allocated() / GB if torch.cuda.is_available() else 0.0,
+ delta_vram_gb=delta_vram_gb,
)
self._stats[graph_execution_state_id].add_node_execution_stats(node_stats)
- def reset_stats(self):
- self._stats = {}
- self._cache_stats = {}
+ def reset_stats(self, graph_execution_state_id: str) -> None:
+ self._stats.pop(graph_execution_state_id, None)
+ self._cache_stats.pop(graph_execution_state_id, None)
def get_stats(self, graph_execution_state_id: str) -> InvocationStatsSummary:
graph_stats_summary = self._get_graph_summary(graph_execution_state_id)
node_stats_summaries = self._get_node_summaries(graph_execution_state_id)
model_cache_stats_summary = self._get_model_cache_summary(graph_execution_state_id)
+ # Note: We use memory_allocated() here (not memory_reserved()) because we want to show
+ # the current actively-used VRAM, not the total reserved memory including PyTorch's cache.
vram_usage_gb = torch.cuda.memory_allocated() / GB if torch.cuda.is_available() else None
return InvocationStatsSummary(
diff --git a/invokeai/app/services/invoker.py b/invokeai/app/services/invoker.py
index 527afb37f44..64f83725a1d 100644
--- a/invokeai/app/services/invoker.py
+++ b/invokeai/app/services/invoker.py
@@ -1,7 +1,7 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
-from .invocation_services import InvocationServices
+from invokeai.app.services.invocation_services import InvocationServices
class Invoker:
diff --git a/invokeai/app/services/model_images/model_images_common.py b/invokeai/app/services/model_images/model_images_common.py
index 0d856f2dfe6..4853a06f6a0 100644
--- a/invokeai/app/services/model_images/model_images_common.py
+++ b/invokeai/app/services/model_images/model_images_common.py
@@ -1,4 +1,4 @@
-# TODO: Should these excpetions subclass existing python exceptions?
+# TODO: Should these exceptions subclass existing python exceptions?
class ModelImageFileNotFoundException(Exception):
"""Raised when an image file is not found in storage."""
diff --git a/invokeai/app/services/model_images/model_images_default.py b/invokeai/app/services/model_images/model_images_default.py
index 0ab79df3ed5..5fe8086c6a5 100644
--- a/invokeai/app/services/model_images/model_images_default.py
+++ b/invokeai/app/services/model_images/model_images_default.py
@@ -2,18 +2,16 @@
from PIL import Image
from PIL.Image import Image as PILImageType
-from send2trash import send2trash
from invokeai.app.services.invoker import Invoker
-from invokeai.app.util.misc import uuid_string
-from invokeai.app.util.thumbnails import make_thumbnail
-
-from .model_images_base import ModelImageFileStorageBase
-from .model_images_common import (
+from invokeai.app.services.model_images.model_images_base import ModelImageFileStorageBase
+from invokeai.app.services.model_images.model_images_common import (
ModelImageFileDeleteException,
ModelImageFileNotFoundException,
ModelImageFileSaveException,
)
+from invokeai.app.util.misc import uuid_string
+from invokeai.app.util.thumbnails import make_thumbnail
class ModelImageFileStorageDisk(ModelImageFileStorageBase):
@@ -71,7 +69,7 @@ def delete(self, model_key: str) -> None:
if not self._validate_path(path):
raise ModelImageFileNotFoundException
- send2trash(path)
+ path.unlink()
except Exception as e:
raise ModelImageFileDeleteException from e
diff --git a/invokeai/app/services/model_install/__init__.py b/invokeai/app/services/model_install/__init__.py
index 941485a1345..d96e86cbfed 100644
--- a/invokeai/app/services/model_install/__init__.py
+++ b/invokeai/app/services/model_install/__init__.py
@@ -1,9 +1,7 @@
"""Initialization file for model install service package."""
-from .model_install_base import (
- ModelInstallServiceBase,
-)
-from .model_install_common import (
+from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase
+from invokeai.app.services.model_install.model_install_common import (
HFModelSource,
InstallStatus,
LocalModelSource,
@@ -12,7 +10,7 @@
UnknownInstallJobException,
URLModelSource,
)
-from .model_install_default import ModelInstallService
+from invokeai.app.services.model_install.model_install_default import ModelInstallService
__all__ = [
"ModelInstallServiceBase",
diff --git a/invokeai/app/services/model_install/model_install_base.py b/invokeai/app/services/model_install/model_install_base.py
index 20afaeaa505..96e1c351415 100644
--- a/invokeai/app/services/model_install/model_install_base.py
+++ b/invokeai/app/services/model_install/model_install_base.py
@@ -3,17 +3,18 @@
from abc import ABC, abstractmethod
from pathlib import Path
-from typing import Any, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, List, Optional, Union
from pydantic.networks import AnyHttpUrl
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.invoker import Invoker
from invokeai.app.services.model_install.model_install_common import ModelInstallJob, ModelSource
-from invokeai.app.services.model_records import ModelRecordServiceBase
-from invokeai.backend.model_manager import AnyModelConfig
+from invokeai.app.services.model_records import ModelRecordChanges, ModelRecordServiceBase
+
+if TYPE_CHECKING:
+ from invokeai.app.services.events.events_base import EventServiceBase
class ModelInstallServiceBase(ABC):
@@ -64,7 +65,7 @@ def event_bus(self) -> Optional["EventServiceBase"]:
def register_path(
self,
model_path: Union[Path, str],
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
) -> str:
"""
Probe and register the model at model_path.
@@ -72,7 +73,7 @@ def register_path(
This keeps the model in its current location.
:param model_path: Filesystem Path to the model.
- :param config: Dict of attributes that will override autoassigned values.
+ :param config: ModelRecordChanges object that will override autoassigned model record values.
:returns id: The string ID of the registered model.
"""
@@ -92,7 +93,7 @@ def unconditionally_delete(self, key: str) -> None:
def install_path(
self,
model_path: Union[Path, str],
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
) -> str:
"""
Probe, register and install the model in the models directory.
@@ -101,7 +102,7 @@ def install_path(
the models directory handled by InvokeAI.
:param model_path: Filesystem Path to the model.
- :param config: Dict of attributes that will override autoassigned values.
+ :param config: ModelRecordChanges object that will override autoassigned model record values.
:returns id: The string ID of the registered model.
"""
@@ -109,14 +110,14 @@ def install_path(
def heuristic_import(
self,
source: str,
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
access_token: Optional[str] = None,
inplace: Optional[bool] = False,
) -> ModelInstallJob:
r"""Install the indicated model using heuristics to interpret user intentions.
:param source: String source
- :param config: Optional dict. Any fields in this dict
+ :param config: Optional ModelRecordChanges object. Any fields in this object
will override corresponding autoassigned probe fields in the
model's config record as described in `import_model()`.
:param access_token: Optional access token for remote sources.
@@ -147,7 +148,7 @@ def heuristic_import(
def import_model(
self,
source: ModelSource,
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
) -> ModelInstallJob:
"""Install the indicated model.
@@ -204,6 +205,22 @@ def prune_jobs(self) -> None:
def cancel_job(self, job: ModelInstallJob) -> None:
"""Cancel the indicated job."""
+ @abstractmethod
+ def pause_job(self, job: ModelInstallJob) -> None:
+ """Pause the indicated job, preserving partial downloads."""
+
+ @abstractmethod
+ def resume_job(self, job: ModelInstallJob) -> None:
+ """Resume a previously paused job."""
+
+ @abstractmethod
+ def restart_failed(self, job: ModelInstallJob) -> None:
+ """Restart failed or non-resumable downloads for a job."""
+
+ @abstractmethod
+ def restart_file(self, job: ModelInstallJob, file_source: str) -> None:
+ """Restart a specific file download for a job."""
+
@abstractmethod
def wait_for_job(self, job: ModelInstallJob, timeout: int = 0) -> ModelInstallJob:
"""Wait for the indicated job to reach a terminal state.
@@ -229,19 +246,6 @@ def wait_for_installs(self, timeout: int = 0) -> List[ModelInstallJob]:
will block indefinitely until the installs complete.
"""
- @abstractmethod
- def sync_model_path(self, key: str) -> AnyModelConfig:
- """
- Move model into the location indicated by its basetype, type and name.
-
- Call this after updating a model's attributes in order to move
- the model's path into the location indicated by its basetype, type and
- name. Applies only to models whose paths are within the root `models_dir`
- directory.
-
- May raise an UnknownModelException.
- """
-
@abstractmethod
def download_and_cache_model(self, source: str | AnyHttpUrl) -> Path:
"""
@@ -254,7 +258,7 @@ def download_and_cache_model(self, source: str | AnyHttpUrl) -> Path:
is periodically cleared of infrequently-used entries when the model
converter runs.
- Note that this doesn't automaticallly install or register the model, but is
+ Note that this doesn't automatically install or register the model, but is
intended for use by nodes that need access to models that aren't directly
supported by InvokeAI. The downloading process takes advantage of the download queue
to avoid interrupting other operations.
diff --git a/invokeai/app/services/model_install/model_install_common.py b/invokeai/app/services/model_install/model_install_common.py
index c1538f543dc..f223c4698c2 100644
--- a/invokeai/app/services/model_install/model_install_common.py
+++ b/invokeai/app/services/model_install/model_install_common.py
@@ -2,16 +2,23 @@
import traceback
from enum import Enum
from pathlib import Path
-from typing import Any, Dict, Literal, Optional, Set, Union
+from typing import Literal, Optional, Set, Union
from pydantic import BaseModel, Field, PrivateAttr, field_validator
from pydantic.networks import AnyHttpUrl
from typing_extensions import Annotated
from invokeai.app.services.download import DownloadJob, MultiFileDownloadJob
-from invokeai.backend.model_manager import AnyModelConfig, ModelRepoVariant
-from invokeai.backend.model_manager.config import ModelSourceType
+from invokeai.app.services.model_records import ModelRecordChanges
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.metadata import AnyModelRepoMetadata
+from invokeai.backend.model_manager.taxonomy import ModelRepoVariant, ModelSourceType
+
+
+class InvalidModelConfigException(Exception):
+ """Raised when a model configuration is invalid."""
+
+ pass
class InstallStatus(str, Enum):
@@ -21,6 +28,7 @@ class InstallStatus(str, Enum):
DOWNLOADING = "downloading" # downloading of model files in process
DOWNLOADS_DONE = "downloads_done" # downloading done, waiting to run
RUNNING = "running" # being processed
+ PAUSED = "paused" # paused, can be resumed
COMPLETED = "completed" # finished running
ERROR = "error" # terminated with an error message
CANCELLED = "cancelled" # terminated with an error message
@@ -78,9 +86,12 @@ def __str__(self) -> str:
class HFModelSource(StringLikeSource):
"""
- A HuggingFace repo_id with optional variant, sub-folder and access token.
+ A HuggingFace repo_id with optional variant, sub-folder(s) and access token.
Note that the variant option, if not provided to the constructor, will default to fp16, which is
what people (almost) always want.
+
+ The subfolder can be a single path or multiple paths joined by '+' (e.g., "text_encoder+tokenizer").
+ When multiple subfolders are specified, all of them will be downloaded and combined into the model directory.
"""
repo_id: str
@@ -96,13 +107,23 @@ def proper_repo_id(cls, v: str) -> str: # noqa D102
raise ValueError(f"{v}: invalid repo_id format")
return v
+ @property
+ def subfolders(self) -> list[Path]:
+ """Return list of subfolders (supports '+' separated multiple subfolders)."""
+ if self.subfolder is None:
+ return []
+ subfolder_str = self.subfolder.as_posix()
+ if "+" in subfolder_str:
+ return [Path(s.strip()) for s in subfolder_str.split("+")]
+ return [self.subfolder]
+
def __str__(self) -> str:
"""Return string version of repoid when string rep needed."""
base: str = self.repo_id
if self.variant:
base += f":{self.variant or ''}"
if self.subfolder:
- base += f":{self.subfolder}"
+ base += f"::{self.subfolder.as_posix()}"
return base
@@ -118,12 +139,27 @@ def __str__(self) -> str:
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,
}
@@ -133,8 +169,9 @@ class ModelInstallJob(BaseModel):
id: int = Field(description="Unique ID for this job")
status: InstallStatus = Field(default=InstallStatus.WAITING, description="Current status of install process")
error_reason: Optional[str] = Field(default=None, description="Information about why the job failed")
- config_in: Dict[str, Any] = Field(
- default_factory=dict, description="Configuration information (e.g. 'description') to apply to model."
+ config_in: ModelRecordChanges = Field(
+ default_factory=ModelRecordChanges,
+ description="Configuration information (e.g. 'description') to apply to model.",
)
config_out: Optional[AnyModelConfig] = Field(
default=None, description="After successful installation, this will hold the configuration object."
@@ -164,6 +201,7 @@ class ModelInstallJob(BaseModel):
_install_tmpdir: Optional[Path] = PrivateAttr(default=None)
_multifile_job: Optional[MultiFileDownloadJob] = PrivateAttr(default=None)
_exception: Optional[Exception] = PrivateAttr(default=None)
+ _resume_metadata: Optional[dict] = PrivateAttr(default=None)
def set_error(self, e: Exception) -> None:
"""Record the error and traceback from an exception."""
@@ -211,6 +249,11 @@ def downloads_done(self) -> bool:
"""Return true if job's downloads ae done."""
return self.status == InstallStatus.DOWNLOADS_DONE
+ @property
+ def paused(self) -> bool:
+ """Return true if job is paused."""
+ return self.status == InstallStatus.PAUSED
+
@property
def running(self) -> bool:
"""Return true if job is running."""
diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py
index dd1b44d8999..7c9fdeee11b 100644
--- a/invokeai/app/services/model_install/model_install_default.py
+++ b/invokeai/app/services/model_install/model_install_default.py
@@ -1,37 +1,57 @@
"""Model installation class."""
+import gc
+import json
import locale
import os
import re
+import sys
import threading
import time
+from copy import deepcopy
from pathlib import Path
from queue import Empty, Queue
-from shutil import copyfile, copytree, move, rmtree
+from shutil import move, rmtree
from tempfile import mkdtemp
-from typing import Any, Dict, List, Optional, Tuple, Type, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union
import torch
import yaml
-from huggingface_hub import HfFolder
+from huggingface_hub import get_token as hf_get_token
from pydantic.networks import AnyHttpUrl
from pydantic_core import Url
from requests import Session
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.download import DownloadQueueServiceBase, MultiFileDownloadJob
-from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase
-from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase
+from invokeai.app.services.model_install.model_install_common import (
+ MODEL_SOURCE_TO_TYPE_MAP,
+ ExternalModelSource,
+ HFModelSource,
+ InstallStatus,
+ InvalidModelConfigException,
+ LocalModelSource,
+ ModelInstallJob,
+ ModelSource,
+ StringLikeSource,
+ URLModelSource,
+)
+from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase, UnknownModelException
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
-from invokeai.backend.model_manager.config import (
+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,
- CheckpointConfigBase,
- InvalidModelConfigException,
- ModelRepoVariant,
- ModelSourceType,
+ ModelConfigFactory,
)
+from invokeai.backend.model_manager.configs.unknown import Unknown_Config
from invokeai.backend.model_manager.metadata import (
AnyModelRepoMetadata,
HuggingFaceMetadataFetch,
@@ -40,25 +60,28 @@
RemoteModelFile,
)
from invokeai.backend.model_manager.metadata.metadata_base import HuggingFaceMetadata
-from invokeai.backend.model_manager.probe import ModelProbe
from invokeai.backend.model_manager.search import ModelSearch
+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
from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.util import slugify
-from .model_install_common import (
- MODEL_SOURCE_TO_TYPE_MAP,
- HFModelSource,
- InstallStatus,
- LocalModelSource,
- ModelInstallJob,
- ModelSource,
- StringLikeSource,
- URLModelSource,
-)
+if TYPE_CHECKING:
+ from invokeai.app.services.events.events_base import EventServiceBase
+
TMPDIR_PREFIX = "tmpinstall_"
+# Marker file used to resume or pause remote model installs across restarts.
+INSTALL_MARKER_FILENAME = ".invokeai_install.json"
+INSTALL_MARKER_VERSION = 1
class ModelInstallService(ModelInstallServiceBase):
@@ -89,13 +112,209 @@ def __init__(
self._stop_event = threading.Event()
self._downloads_changed_event = threading.Event()
self._install_completed_event = threading.Event()
+ self._restore_completed_event = threading.Event()
+ self._restore_completed_event.set()
self._download_queue = download_queue
self._download_cache: Dict[int, ModelInstallJob] = {}
+ # Per-source locks serializing download_and_cache_model() so parallel (multi-GPU) sessions
+ # that need the same remote model (e.g. the LaMa infill model) don't race to download into
+ # the same cache directory. _download_cache_locks_guard protects the dict itself.
+ self._download_cache_locks: Dict[str, threading.Lock] = {}
+ self._download_cache_locks_guard = threading.Lock()
self._running = False
self._session = session
self._install_thread: Optional[threading.Thread] = None
self._next_job_id = 0
+ def _marker_path(self, tmpdir: Path) -> Path:
+ return tmpdir / INSTALL_MARKER_FILENAME
+
+ def _write_install_marker(self, job: ModelInstallJob, status: Optional[InstallStatus] = None) -> None:
+ if job._install_tmpdir is None:
+ return
+ files: list[dict] = []
+ if job.download_parts:
+ for part in job.download_parts:
+ files.append(
+ {
+ "url": str(part.source),
+ "canonical_url": part.canonical_url,
+ "etag": part.etag,
+ "last_modified": part.last_modified,
+ "expected_total_bytes": part.expected_total_bytes,
+ "final_url": part.final_url,
+ "download_path": part.download_path.as_posix() if part.download_path else None,
+ "resume_required": part.resume_required,
+ "resume_message": part.resume_message,
+ }
+ )
+ marker = {
+ "version": INSTALL_MARKER_VERSION,
+ "source": str(job.source),
+ "access_token": (
+ job.source.access_token if isinstance(job.source, (HFModelSource, URLModelSource)) else None
+ ),
+ "config_in": job.config_in.model_dump(),
+ "status": (status or job.status).value,
+ "updated_at": get_iso_timestamp(),
+ "files": files,
+ }
+ path = self._marker_path(job._install_tmpdir)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "wt", encoding="utf-8") as f:
+ json.dump(marker, f)
+
+ def _read_install_marker(self, tmpdir: Path) -> Optional[dict]:
+ path = self._marker_path(tmpdir)
+ if not path.exists():
+ return None
+ try:
+ with open(path, "rt", encoding="utf-8") as f:
+ marker = json.load(f)
+ if marker.get("version") != INSTALL_MARKER_VERSION:
+ return None
+ return marker
+ except Exception as e:
+ self._logger.warning(f"Invalid install marker in {tmpdir}: {e}")
+ return None
+
+ def _delete_install_marker(self, tmpdir: Path) -> None:
+ path = self._marker_path(tmpdir)
+ if path.exists():
+ try:
+ path.unlink()
+ except Exception as e:
+ self._logger.warning(f"Failed to remove install marker {path}: {e}")
+
+ def _find_reusable_tmpdir(self, source: ModelSource) -> Optional[Path]:
+ path = self._app_config.models_path
+ source_str = str(source)
+ candidates: list[tuple[str, Path]] = []
+ for tmpdir in path.glob(f"{TMPDIR_PREFIX}*"):
+ marker = self._read_install_marker(tmpdir)
+ if not marker:
+ continue
+ if marker.get("source") != source_str:
+ continue
+ status = marker.get("status")
+ if status in {InstallStatus.COMPLETED.value, InstallStatus.ERROR.value, InstallStatus.CANCELLED.value}:
+ continue
+ candidates.append((marker.get("updated_at", ""), tmpdir))
+ if not candidates:
+ return None
+ candidates.sort(key=lambda item: item[0], reverse=True)
+ return candidates[0][1]
+
+ def _restore_incomplete_installs(self) -> None:
+ path = self._app_config.models_path
+ seen_sources: set[str] = set()
+ # Collect sources already tracked by active jobs (including those being downloaded right now).
+ # We must not re-queue these or delete their tmpdirs.
+ with self._lock:
+ active_sources = {str(j.source) for j in self._install_jobs if not j.in_terminal_state}
+ active_sources.update(str(j.source) for j in self._download_cache.values() if not j.in_terminal_state)
+ for tmpdir in path.glob(f"{TMPDIR_PREFIX}*"):
+ marker = self._read_install_marker(tmpdir)
+ if not marker:
+ continue
+ status = marker.get("status")
+ if status in {InstallStatus.COMPLETED.value, InstallStatus.ERROR.value, InstallStatus.CANCELLED.value}:
+ continue
+
+ try:
+ source_str = marker.get("source")
+ if not isinstance(source_str, str):
+ raise ValueError("Missing source in install marker")
+ source = self._guess_source(source_str)
+ access_token = marker.get("access_token")
+ if isinstance(source, (HFModelSource, URLModelSource)) and isinstance(access_token, str):
+ source.access_token = access_token
+ if source_str in active_sources:
+ # This tmpdir belongs to an install already in progress; leave it alone.
+ self._logger.debug(f"Skipping restore for {source_str} - already being tracked")
+ continue
+ if source_str in seen_sources:
+ self._logger.info(f"Removing duplicate temporary directory {tmpdir}")
+ self._safe_rmtree(tmpdir, self._logger)
+ continue
+ seen_sources.add(source_str)
+ except Exception as e:
+ self._logger.warning(f"Skipping install marker in {tmpdir}: {e}")
+ continue
+
+ config_in = ModelRecordChanges(**(marker.get("config_in") or {}))
+ job = ModelInstallJob(
+ id=self._next_id(),
+ source=source,
+ config_in=config_in,
+ local_path=tmpdir,
+ )
+ job._install_tmpdir = tmpdir
+ files_meta = marker.get("files") or []
+ if files_meta:
+ job._resume_metadata = {f.get("url"): f for f in files_meta if f.get("url")}
+ job.status = InstallStatus(status) if status else InstallStatus.WAITING
+ self._install_jobs.append(job)
+
+ if job.paused:
+ continue
+
+ if job.status in [InstallStatus.DOWNLOADS_DONE, InstallStatus.RUNNING]:
+ job.status = InstallStatus.DOWNLOADS_DONE
+ self._put_in_queue(job)
+ else:
+ try:
+ self._resume_remote_download(job)
+ except Exception as e:
+ self._set_error(job, e)
+ if job._install_tmpdir is not None:
+ self._safe_rmtree(job._install_tmpdir, self._logger)
+
+ def _restore_incomplete_installs_async(self) -> None:
+ self._restore_completed_event.clear()
+
+ def _run() -> None:
+ try:
+ self._logger.info("Restoring incomplete installs")
+ self._restore_incomplete_installs()
+ self._logger.info("Finished restoring incomplete installs")
+ except Exception as e:
+ self._logger.error(f"Failed to restore incomplete installs: {e}")
+ finally:
+ self._restore_completed_event.set()
+
+ threading.Thread(target=_run, daemon=True).start()
+
+ def _wait_for_restore_complete(self) -> None:
+ self._restore_completed_event.wait()
+
+ def _resume_remote_download(self, job: ModelInstallJob) -> None:
+ job.status = InstallStatus.WAITING
+ if job.download_parts:
+ for part in job.download_parts:
+ if part.complete or part.bytes <= 0:
+ continue
+ if not part.download_path:
+ continue
+ in_progress_path = part.download_path.with_name(part.download_path.name + ".downloading")
+ if not in_progress_path.exists():
+ part.bytes = 0
+ part.resume_from_scratch = True
+ part.resume_message = "Partial file missing. Restarted download from the beginning."
+ job.bytes = sum(p.bytes for p in job.download_parts)
+ remote_files, metadata = self._remote_files_from_source(job.source)
+ subfolders = job.source.subfolders if isinstance(job.source, HFModelSource) else []
+ self._enqueue_remote_download(
+ job=job,
+ source=job.source,
+ remote_files=remote_files,
+ metadata=metadata,
+ destdir=job._install_tmpdir or job.local_path,
+ subfolder=job.source.subfolder if isinstance(job.source, HFModelSource) and len(subfolders) <= 1 else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
+ resume_metadata=job._resume_metadata,
+ )
+
@property
def app_config(self) -> InvokeAIAppConfig: # noqa D102
return self._app_config
@@ -131,10 +350,13 @@ def start(self, invoker: Optional[Invoker] = None) -> None:
for model in self._scan_for_missing_models():
self._logger.warning(f"Missing model file: {model.name} at {model.path}")
+ self._write_invoke_managed_models_dir_readme()
+ self._restore_incomplete_installs_async()
+
def stop(self, invoker: Optional[Invoker] = None) -> None:
"""Stop the installer thread; after this the object can be deleted and garbage collected."""
if not self._running:
- raise Exception("Attempt to stop the install service before it was started")
+ return
self._logger.debug("calling stop_event.set()")
self._stop_event.set()
self._clear_pending_jobs()
@@ -143,11 +365,23 @@ def stop(self, invoker: Optional[Invoker] = None) -> None:
self._install_thread.join()
self._running = False
+ def _write_invoke_managed_models_dir_readme(self) -> None:
+ """Write a README file to the Invoke-managed models directory warning users to not fiddle with it."""
+ readme_path = self.app_config.models_path / "README.txt"
+ with open(readme_path, "wt", encoding=locale.getpreferredencoding()) as f:
+ f.write(
+ "This directory is managed by Invoke. Do not add, delete or move files in this directory.\n\nTo manage models, use the web interface.\n"
+ )
+
def _clear_pending_jobs(self) -> None:
for job in self.list_jobs():
if not job.in_terminal_state:
- self._logger.warning("Cancelling job {job.id}")
- self.cancel_job(job)
+ if job._multifile_job is not None:
+ self._logger.warning(f"Pausing job {job.id}")
+ self.pause_job(job)
+ else:
+ self._logger.warning(f"Cancelling job {job.id}")
+ self.cancel_job(job)
while True:
try:
job = self._install_queue.get(block=False)
@@ -164,40 +398,61 @@ def _put_in_queue(self, job: ModelInstallJob) -> None:
def register_path(
self,
model_path: Union[Path, str],
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
) -> str: # noqa D102
model_path = Path(model_path)
- config = config or {}
- if not config.get("source"):
- config["source"] = model_path.resolve().as_posix()
- config["source_type"] = ModelSourceType.Path
+ config = config or ModelRecordChanges()
+ if not config.source:
+ config.source = model_path.resolve().as_posix()
+ config.source_type = ModelSourceType.Path
return self._register(model_path, config)
+ # TODO: Replace this with a proper fix for underlying problem of Windows holding open
+ # the file when it needs to be moved.
+ @staticmethod
+ def _move_with_retries(src: Path, dst: Path, attempts: int = 5, delay: float = 0.5) -> None:
+ """Workaround for Windows file-handle issues when moving files."""
+ for tries_left in range(attempts, 0, -1):
+ try:
+ move(src, dst)
+ return
+ except PermissionError:
+ gc.collect()
+ if tries_left == 1:
+ raise
+ time.sleep(delay)
+ delay *= 2 # Exponential backoff
+
def install_path(
self,
model_path: Union[Path, str],
- config: Optional[Dict[str, Any]] = None,
- ) -> str: # noqa D102
+ config: Optional[ModelRecordChanges] = None,
+ ) -> str:
model_path = Path(model_path)
- config = config or {}
-
- info: AnyModelConfig = ModelProbe.probe(Path(model_path), config, hash_algo=self._app_config.hashing_algorithm)
-
- if preferred_name := config.get("name"):
- preferred_name = Path(preferred_name).with_suffix(model_path.suffix)
+ config = config or ModelRecordChanges()
+ info: AnyModelConfig = self._probe(Path(model_path), config) # type: ignore
- dest_path = (
- self.app_config.models_path / info.base.value / info.type.value / (preferred_name or model_path.name)
- )
+ dest_dir = self.app_config.models_path / info.key
try:
- new_path = self._copy_model(model_path, dest_path)
- except FileExistsError as excp:
+ if dest_dir.exists():
+ raise FileExistsError(
+ f"Cannot install model {model_path.name} to {dest_dir}: destination already exists"
+ )
+ dest_dir.mkdir(parents=True)
+ dest_path = dest_dir / model_path.name if model_path.is_file() else dest_dir
+ if model_path.is_file():
+ self._move_with_retries(model_path, dest_path) # Windows workaround TODO: fix root cause
+ elif model_path.is_dir():
+ # Move the contents of the directory, not the directory itself
+ for item in model_path.iterdir():
+ move(item, dest_dir / item.name)
+ except FileExistsError as e:
raise DuplicateModelException(
- f"A model named {model_path.name} is already installed at {dest_path.as_posix()}"
- ) from excp
+ f"A model named {model_path.name} is already installed at {dest_dir.as_posix()}"
+ ) from e
return self._register(
- new_path,
+ dest_path,
config,
info,
)
@@ -205,7 +460,7 @@ def install_path(
def heuristic_import(
self,
source: str,
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
access_token: Optional[str] = None,
inplace: Optional[bool] = False,
) -> ModelInstallJob:
@@ -217,7 +472,9 @@ def heuristic_import(
source_obj.access_token = access_token
return self.import_model(source_obj, config)
- def import_model(self, source: ModelSource, config: Optional[Dict[str, Any]] = None) -> ModelInstallJob: # noqa D102
+ def import_model(self, source: ModelSource, config: Optional[ModelRecordChanges] = None) -> ModelInstallJob: # noqa D102
+ self._wait_for_restore_complete()
+
similar_jobs = [x for x in self.list_jobs() if x.source == source and not x.in_terminal_state]
if similar_jobs:
self._logger.warning(f"There is already an active install job for {source}. Not enqueuing.")
@@ -230,6 +487,9 @@ def import_model(self, source: ModelSource, config: Optional[Dict[str, Any]] = N
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)}'")
@@ -262,6 +522,8 @@ def wait_for_job(self, job: ModelInstallJob, timeout: int = 0) -> ModelInstallJo
def wait_for_installs(self, timeout: int = 0) -> List[ModelInstallJob]: # noqa D102
"""Block until all installation jobs are done."""
+ self._wait_for_restore_complete()
+
start = time.time()
while len(self._download_cache) > 0:
if self._downloads_changed_event.wait(timeout=0.25): # in case we miss an event
@@ -278,6 +540,76 @@ def cancel_job(self, job: ModelInstallJob) -> None:
self._logger.warning(f"Cancelling {job.source}")
if dj := job._multifile_job:
self._download_queue.cancel_job(dj)
+ if job._install_tmpdir is not None:
+ # Mark cancelled before cleanup so we don't reuse the folder if deletion fails.
+ self._write_install_marker(job, status=InstallStatus.CANCELLED)
+ self._delete_install_marker(job._install_tmpdir)
+ self._safe_rmtree(job._install_tmpdir, self._logger)
+
+ def pause_job(self, job: ModelInstallJob) -> None:
+ """Pause the indicated job, preserving partial downloads."""
+ if job.in_terminal_state:
+ return
+ job.status = InstallStatus.PAUSED
+ self._logger.warning(f"Pausing {job.source}")
+ if dj := job._multifile_job:
+ for part in dj.download_parts:
+ self._download_queue.pause_job(part)
+ self._write_install_marker(job, status=InstallStatus.PAUSED)
+
+ def resume_job(self, job: ModelInstallJob) -> None:
+ """Resume a previously paused job."""
+ if not job.paused:
+ return
+ self._logger.info(f"Resuming {job.source}")
+ self._resume_remote_download(job)
+
+ def restart_failed(self, job: ModelInstallJob) -> None:
+ """Restart failed or non-resumable downloads for a job."""
+ if not isinstance(job.source, (HFModelSource, URLModelSource)):
+ return
+ if not job.download_parts:
+ return
+ if not any(part.resume_required or part.errored for part in job.download_parts):
+ return
+ sources_to_restart = {str(part.source) for part in job.download_parts if not part.complete}
+ if not sources_to_restart:
+ return
+ job.status = InstallStatus.WAITING
+ remote_files, metadata = self._remote_files_from_source(job.source)
+ remote_files = [rf for rf in remote_files if str(rf.url) in sources_to_restart]
+ subfolders = job.source.subfolders if isinstance(job.source, HFModelSource) else []
+ self._enqueue_remote_download(
+ job=job,
+ source=job.source,
+ remote_files=remote_files,
+ metadata=metadata,
+ destdir=job._install_tmpdir or job.local_path,
+ subfolder=job.source.subfolder if isinstance(job.source, HFModelSource) and len(subfolders) <= 1 else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
+ clear_partials=True,
+ )
+
+ def restart_file(self, job: ModelInstallJob, file_source: str) -> None:
+ """Restart a specific file download for a job."""
+ if not isinstance(job.source, (HFModelSource, URLModelSource)):
+ return
+ job.status = InstallStatus.WAITING
+ remote_files, metadata = self._remote_files_from_source(job.source)
+ remote_files = [rf for rf in remote_files if str(rf.url) == file_source]
+ if not remote_files:
+ return
+ subfolders = job.source.subfolders if isinstance(job.source, HFModelSource) else []
+ self._enqueue_remote_download(
+ job=job,
+ source=job.source,
+ remote_files=remote_files,
+ metadata=metadata,
+ destdir=job._install_tmpdir or job.local_path,
+ subfolder=job.source.subfolder if isinstance(job.source, HFModelSource) and len(subfolders) <= 1 else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
+ clear_partials=True,
+ )
def prune_jobs(self) -> None:
"""Prune all completed and errored jobs."""
@@ -319,16 +651,17 @@ def _migrate_yaml(self) -> None:
model_path = self._app_config.models_path / model_path
model_path = model_path.resolve()
- config: dict[str, Any] = {}
- config["name"] = model_name
- config["description"] = stanza.get("description")
+ config = ModelRecordChanges(
+ name=model_name,
+ description=stanza.get("description"),
+ )
legacy_config_path = stanza.get("config")
if legacy_config_path:
# In v3, these paths were relative to the root. Migrate them to be relative to the legacy_conf_dir.
legacy_config_path = self._app_config.root_path / legacy_config_path
if legacy_config_path.is_relative_to(self._app_config.legacy_conf_path):
legacy_config_path = legacy_config_path.relative_to(self._app_config.legacy_conf_path)
- config["config_path"] = str(legacy_config_path)
+ config.config_path = str(legacy_config_path)
try:
id = self.register_path(model_path=model_path, config=config)
self._logger.info(f"Migrated {model_name} with id {id}")
@@ -359,9 +692,20 @@ def delete(self, key: str) -> None: # noqa D102
def unconditionally_delete(self, key: str) -> None: # noqa D102
model = self.record_store.get_model(key)
model_path = self.app_config.models_path / model.path
+ # Models are stored in a directory named by their key. To delete the model on disk, we delete the entire
+ # directory. However, the path we store in the model record may be either a file within the key directory,
+ # or the directory itself. So we have to handle both cases.
if model_path.is_file() or model_path.is_symlink():
+ # Delete the individual model file, not the entire parent directory.
+ # Other unrelated files may exist in the same directory.
model_path.unlink()
+ # Clean up the parent directory only if it is now empty
+ if model_path.parent != self.app_config.models_path and not any(model_path.parent.iterdir()):
+ model_path.parent.rmdir()
elif model_path.is_dir():
+ # Sanity check - folder models should be in their own directory under the models dir. The path should
+ # not be the Invoke models dir itself!
+ assert model_path != self.app_config.models_path
rmtree(model_path)
self.unregister(key)
@@ -385,22 +729,47 @@ def download_and_cache_model(
if len(contents) > 0:
return contents[0]
- model_path.mkdir(parents=True, exist_ok=True)
- model_source = self._guess_source(str(source))
- remote_files, _ = self._remote_files_from_source(model_source)
- job = self._multifile_download(
- dest=model_path,
- remote_files=remote_files,
- subfolder=model_source.subfolder if isinstance(model_source, HFModelSource) else None,
- )
- files_string = "file" if len(remote_files) == 1 else "files"
- self._logger.info(f"Queuing model download: {source} ({len(remote_files)} {files_string})")
- self._download_queue.wait_for_job(job)
- if job.complete:
- assert job.download_path is not None
- return job.download_path
- else:
- raise Exception(job.error)
+ # Serialize concurrent downloads of the same source. Parallel multi-GPU sessions can each
+ # request the same remote model (e.g. the LaMa infill model) at once; without this lock they
+ # both download into the same cache directory and collide on the final rename, which fails on
+ # Windows with "WinError 32: the file is being used by another process". The other waiters
+ # find the completed download on the post-lock re-check below and skip downloading.
+ with self._download_cache_lock(str(source)):
+ if model_path.exists():
+ contents = list(model_path.iterdir())
+ if len(contents) > 0:
+ return contents[0]
+
+ model_path.mkdir(parents=True, exist_ok=True)
+ model_source = self._guess_source(str(source))
+ remote_files, _ = self._remote_files_from_source(model_source)
+ # Handle multiple subfolders for HFModelSource
+ subfolders = model_source.subfolders if isinstance(model_source, HFModelSource) else []
+ job = self._multifile_download(
+ dest=model_path,
+ remote_files=remote_files,
+ subfolder=model_source.subfolder
+ if isinstance(model_source, HFModelSource) and len(subfolders) <= 1
+ else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
+ )
+ files_string = "file" if len(remote_files) == 1 else "files"
+ self._logger.info(f"Queuing model download: {source} ({len(remote_files)} {files_string})")
+ self._download_queue.wait_for_job(job)
+ if job.complete:
+ assert job.download_path is not None
+ return job.download_path
+ else:
+ raise Exception(job.error)
+
+ def _download_cache_lock(self, source: str) -> threading.Lock:
+ """Return the lock that serializes downloads for a given source, creating it on first use."""
+ with self._download_cache_locks_guard:
+ lock = self._download_cache_locks.get(source)
+ if lock is None:
+ lock = threading.Lock()
+ self._download_cache_locks[source] = lock
+ return lock
def _remote_files_from_source(
self, source: ModelSource
@@ -409,10 +778,13 @@ def _remote_files_from_source(
if isinstance(source, HFModelSource):
metadata = HuggingFaceMetadataFetch(self._session).from_id(source.repo_id, source.variant)
assert isinstance(metadata, ModelMetadataWithFiles)
+ # Use subfolders property which handles '+' separated multiple subfolders
+ subfolders = source.subfolders
return (
metadata.download_urls(
variant=source.variant or self._guess_variant(),
- subfolder=source.subfolder,
+ subfolder=source.subfolder if len(subfolders) <= 1 else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
session=self._session,
),
metadata,
@@ -428,7 +800,7 @@ def _remote_files_from_source(
except ValueError:
pass
- return [RemoteModelFile(url=source.url, path=Path("."), size=0)], None
+ return [RemoteModelFile(url=self._normalize_huggingface_blob_url(source.url), path=Path("."), size=0)], None
raise Exception(f"No files associated with {source}")
@@ -437,9 +809,16 @@ def _guess_source(self, source: str) -> ModelSource:
variants = "|".join(ModelRepoVariant.__members__.values())
hf_repoid_re = f"^([^/:]+/[^/:]+)(?::({variants})?(?::/?([^:]+))?)?$"
source_obj: Optional[StringLikeSource] = None
-
- if Path(source).exists(): # A local file or directory
- source_obj = LocalModelSource(path=Path(source))
+ source_stripped = source.strip('"')
+
+ 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(
repo_id=match.group(1),
@@ -462,6 +841,39 @@ def _start_installer_thread(self) -> None:
self._install_thread.start()
self._running = True
+ @staticmethod
+ def _safe_rmtree(path: Path, logger: Any) -> None:
+ """Remove a directory tree with retry logic for Windows file locking issues.
+
+ On Windows, memory-mapped files may not be immediately released even after
+ the file handle is closed. This function retries the removal with garbage
+ collection to help release any lingering references.
+ """
+ max_retries = 3
+ retry_delay = 0.5 # seconds
+
+ for attempt in range(max_retries):
+ try:
+ # Force garbage collection to release any lingering file references
+ gc.collect()
+ rmtree(path)
+ return
+ except PermissionError as e:
+ if attempt < max_retries - 1 and sys.platform == "win32":
+ logger.warning(
+ f"Failed to remove {path} (attempt {attempt + 1}/{max_retries}): {e}. "
+ f"Retrying in {retry_delay}s..."
+ )
+ time.sleep(retry_delay)
+ retry_delay *= 2 # Exponential backoff
+ else:
+ logger.error(f"Failed to remove temporary directory {path}: {e}")
+ # On final failure, don't raise - the temp dir will be cleaned up on next startup
+ return
+ except Exception as e:
+ logger.error(f"Unexpected error removing {path}: {e}")
+ return
+
def _install_next_item(self) -> None:
self._logger.debug(f"Installer thread {threading.get_ident()} starting")
while True:
@@ -491,21 +903,27 @@ def _install_next_item(self) -> None:
finally:
# if this is an install of a remote file, then clean up the temporary directory
if job._install_tmpdir is not None:
- rmtree(job._install_tmpdir)
+ self._safe_rmtree(job._install_tmpdir, self._logger)
self._install_completed_event.set()
self._install_queue.task_done()
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
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__]
+ job.config_in.source = str(job.source)
+ job.config_in.source_type = MODEL_SOURCE_TO_TYPE_MAP[job.source.__class__]
# enter the metadata, if there is any
if isinstance(job.source_metadata, (HuggingFaceMetadata)):
- job.config_in["source_api_response"] = job.source_metadata.api_response
+ job.config_in.source_api_response = job.source_metadata.api_response
+
+ if job._install_tmpdir is not None:
+ self._delete_install_marker(job._install_tmpdir)
if job.inplace:
key = self.register_path(job.local_path, job.config_in)
@@ -514,13 +932,78 @@ def _register_or_install(self, job: ModelInstallJob) -> None:
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(
x.content_type is not None and "text/html" in x.content_type for x in multifile_download_job.download_parts
):
install_job.set_error(
- InvalidModelConfigException(
+ ValueError(
f"At least one file in {install_job.local_path} is an HTML page, not a model. This can happen when an access token is required to download."
)
)
@@ -535,13 +1018,22 @@ def _remove_dangling_install_dirs(self) -> None:
"""Remove leftover tmpdirs from aborted installs."""
path = self._app_config.models_path
for tmpdir in path.glob(f"{TMPDIR_PREFIX}*"):
- self._logger.info(f"Removing dangling temporary directory {tmpdir}")
- rmtree(tmpdir)
+ marker = self._read_install_marker(tmpdir)
+ if marker is None:
+ self._logger.info(f"Removing dangling temporary directory {tmpdir}")
+ self._safe_rmtree(tmpdir, self._logger)
+ continue
+ status = marker.get("status")
+ if status in {InstallStatus.COMPLETED.value, InstallStatus.ERROR.value, InstallStatus.CANCELLED.value}:
+ self._logger.info(f"Removing completed/errored temporary directory {tmpdir}")
+ self._safe_rmtree(tmpdir, self._logger)
def _scan_for_missing_models(self) -> list[AnyModelConfig]:
"""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
@@ -583,68 +1075,36 @@ def on_model_found(model_path: Path) -> bool:
found_models = search.search(self._app_config.models_path)
self._logger.info(f"{len(found_models)} new models registered")
- def sync_model_path(self, key: str) -> AnyModelConfig:
- """
- Move model into the location indicated by its basetype, type and name.
-
- Call this after updating a model's attributes in order to move
- the model's path into the location indicated by its basetype, type and
- name. Applies only to models whose paths are within the root `models_dir`
- directory.
+ def _probe(self, model_path: Path, config: Optional[ModelRecordChanges] = None):
+ config = config or ModelRecordChanges()
+ hash_algo = self._app_config.hashing_algorithm
+ fields = config.model_dump()
- May raise an UnknownModelException.
- """
- model = self.record_store.get_model(key)
- models_dir = self.app_config.models_path
- old_path = self.app_config.models_path / model.path
-
- if not old_path.is_relative_to(models_dir):
- # The model is not in the models directory - we don't need to move it.
- return model
-
- new_path = models_dir / model.base.value / model.type.value / old_path.name
-
- if old_path == new_path or new_path.exists() and old_path == new_path.resolve():
- return model
-
- self._logger.info(f"Moving {model.name} to {new_path}.")
- new_path = self._move_model(old_path, new_path)
- model.path = new_path.relative_to(models_dir).as_posix()
- self.record_store.update_model(key, ModelRecordChanges(path=model.path))
- return model
-
- def _copy_model(self, old_path: Path, new_path: Path) -> Path:
- if old_path == new_path:
- return old_path
- new_path.parent.mkdir(parents=True, exist_ok=True)
- if old_path.is_dir():
- copytree(old_path, new_path)
- else:
- copyfile(old_path, new_path)
- return new_path
-
- def _move_model(self, old_path: Path, new_path: Path) -> Path:
- if old_path == new_path:
- return old_path
+ result = ModelConfigFactory.from_model_on_disk(
+ mod=model_path,
+ override_fields=deepcopy(fields),
+ hash_algo=hash_algo,
+ allow_unknown=self.app_config.allow_unknown_models,
+ )
- new_path.parent.mkdir(parents=True, exist_ok=True)
+ if result.config is None:
+ self._logger.error(f"Could not identify model for {model_path}, detailed results: {result.details}")
+ raise InvalidModelConfigException(f"Could not identify model for {model_path}")
+ elif isinstance(result.config, Unknown_Config):
+ self._logger.error(f"Could not identify model for {model_path}, detailed results: {result.details}")
- # if path already exists then we jigger the name to make it unique
- counter: int = 1
- while new_path.exists():
- path = new_path.with_stem(new_path.stem + f"_{counter:02d}")
- if not path.exists():
- new_path = path
- counter += 1
- move(old_path, new_path)
- return new_path
+ return result.config
def _register(
- self, model_path: Path, config: Optional[Dict[str, Any]] = None, info: Optional[AnyModelConfig] = None
+ self, model_path: Path, config: Optional[ModelRecordChanges] = None, info: Optional[AnyModelConfig] = None
) -> str:
- config = config or {}
+ config = config or ModelRecordChanges()
- info = info or ModelProbe.probe(model_path, config, hash_algo=self._app_config.hashing_algorithm)
+ info = info or self._probe(model_path, config)
+
+ # Apply LoRA metadata if applicable
+ model_images_path = self.app_config.models_path / "model_images"
+ apply_lora_metadata(info, model_path.resolve(), model_images_path)
model_path = model_path.resolve()
@@ -654,7 +1114,7 @@ def _register(
info.path = model_path.as_posix()
- if isinstance(info, CheckpointConfigBase):
+ if isinstance(info, Checkpoint_Config_Base) and info.config_path is not None:
# Checkpoints have a config file needed for conversion. Same handling as the model weights - if it's in the
# invoke-managed legacy config dir, we use a relative path.
legacy_config_path = self.app_config.legacy_conf_path / info.config_path
@@ -675,11 +1135,13 @@ def _guess_variant(self) -> Optional[ModelRepoVariant]:
precision = TorchDevice.choose_torch_dtype()
return ModelRepoVariant.FP16 if precision == torch.float16 else None
- def _import_local_model(self, source: LocalModelSource, config: Optional[Dict[str, Any]]) -> ModelInstallJob:
+ def _import_local_model(
+ self, source: LocalModelSource, config: Optional[ModelRecordChanges] = None
+ ) -> ModelInstallJob:
return ModelInstallJob(
id=self._next_id(),
source=source,
- config_in=config or {},
+ config_in=config or ModelRecordChanges(),
local_path=Path(source.path),
inplace=source.inplace or False,
)
@@ -687,11 +1149,11 @@ def _import_local_model(self, source: LocalModelSource, config: Optional[Dict[st
def _import_from_hf(
self,
source: HFModelSource,
- config: Optional[Dict[str, Any]] = None,
+ config: Optional[ModelRecordChanges] = None,
) -> ModelInstallJob:
# Add user's cached access token to HuggingFace requests
if source.access_token is None:
- source.access_token = HfFolder.get_token()
+ source.access_token = hf_get_token()
remote_files, metadata = self._remote_files_from_source(source)
return self._import_remote_model(
source=source,
@@ -703,7 +1165,7 @@ def _import_from_hf(
def _import_from_url(
self,
source: URLModelSource,
- config: Optional[Dict[str, Any]],
+ config: Optional[ModelRecordChanges] = None,
) -> ModelInstallJob:
remote_files, metadata = self._remote_files_from_source(source)
return self._import_remote_model(
@@ -713,25 +1175,40 @@ def _import_from_url(
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,
remote_files: List[RemoteModelFile],
metadata: Optional[AnyModelRepoMetadata],
- config: Optional[Dict[str, Any]],
+ config: Optional[ModelRecordChanges],
) -> ModelInstallJob:
if len(remote_files) == 0:
raise ValueError(f"{source}: No downloadable files found")
- destdir = Path(
- mkdtemp(
- dir=self._app_config.models_path,
- prefix=TMPDIR_PREFIX,
+ destdir = self._find_reusable_tmpdir(source)
+ if destdir is None:
+ destdir = Path(
+ mkdtemp(
+ dir=self._app_config.models_path,
+ prefix=TMPDIR_PREFIX,
+ )
)
- )
install_job = ModelInstallJob(
id=self._next_id(),
source=source,
- config_in=config or {},
+ config_in=config or ModelRecordChanges(),
source_metadata=metadata,
local_path=destdir, # local path may change once the download has started due to content-disposition handling
bytes=0,
@@ -739,23 +1216,81 @@ def _import_remote_model(
)
# remember the temporary directory for later removal
install_job._install_tmpdir = destdir
- install_job.total_bytes = sum((x.size or 0) for x in remote_files)
+
+ # Handle multiple subfolders for HFModelSource
+ subfolders = source.subfolders if isinstance(source, HFModelSource) else []
+ return self._enqueue_remote_download(
+ job=install_job,
+ source=source,
+ remote_files=remote_files,
+ metadata=metadata,
+ destdir=destdir,
+ subfolder=source.subfolder if isinstance(source, HFModelSource) and len(subfolders) <= 1 else None,
+ subfolders=subfolders if len(subfolders) > 1 else None,
+ )
+
+ def _enqueue_remote_download(
+ self,
+ job: ModelInstallJob,
+ source: HFModelSource | URLModelSource,
+ remote_files: List[RemoteModelFile],
+ metadata: Optional[AnyModelRepoMetadata],
+ destdir: Path,
+ subfolder: Optional[Path] = None,
+ subfolders: Optional[List[Path]] = None,
+ resume_metadata: Optional[dict] = None,
+ clear_partials: bool = False,
+ ) -> ModelInstallJob:
+ job.source_metadata = metadata
+ job.local_path = destdir
+ job._install_tmpdir = destdir
+ job.total_bytes = sum((x.size or 0) for x in remote_files)
multifile_job = self._multifile_download(
remote_files=remote_files,
dest=destdir,
- subfolder=source.subfolder if isinstance(source, HFModelSource) else None,
+ subfolder=subfolder,
+ subfolders=subfolders,
access_token=source.access_token,
submit_job=False, # Important! Don't submit the job until we have set our _download_cache dict
)
- self._download_cache[multifile_job.id] = install_job
- install_job._multifile_job = multifile_job
-
+ if clear_partials:
+ for part in multifile_job.download_parts:
+ target_path = part.dest
+ if target_path.exists():
+ try:
+ self._logger.info(f"Deleting partial file before restart: {target_path}")
+ target_path.unlink()
+ except Exception:
+ pass
+ in_progress_path = target_path.with_name(target_path.name + ".downloading")
+ if in_progress_path.exists():
+ try:
+ self._logger.info(f"Deleting partial file before restart: {in_progress_path}")
+ in_progress_path.unlink()
+ except Exception:
+ pass
+ if resume_metadata:
+ for part in multifile_job.download_parts:
+ meta = resume_metadata.get(str(part.source))
+ if not meta:
+ continue
+ part.canonical_url = meta.get("canonical_url") or part.canonical_url
+ part.etag = meta.get("etag") or part.etag
+ part.last_modified = meta.get("last_modified") or part.last_modified
+ part.expected_total_bytes = meta.get("expected_total_bytes") or part.expected_total_bytes
+ part.final_url = meta.get("final_url") or part.final_url
+ if meta.get("download_path"):
+ part.download_path = Path(meta.get("download_path"))
+ self._download_cache[multifile_job.id] = job
+ job._multifile_job = multifile_job
+
+ self._write_install_marker(job, status=InstallStatus.WAITING)
files_string = "file" if len(remote_files) == 1 else "files"
self._logger.info(f"Queueing model install: {source} ({len(remote_files)} {files_string})")
self._logger.debug(f"remote_files={remote_files}")
self._download_queue.submit_multifile_download(multifile_job)
- return install_job
+ return job
def _stat_size(self, path: Path) -> int:
size = 0
@@ -771,6 +1306,7 @@ def _multifile_download(
remote_files: List[RemoteModelFile],
dest: Path,
subfolder: Optional[Path] = None,
+ subfolders: Optional[List[Path]] = None,
access_token: Optional[str] = None,
submit_job: bool = True,
) -> MultiFileDownloadJob:
@@ -778,23 +1314,61 @@ def _multifile_download(
# we are installing the "vae" subfolder, we do not want to create an additional folder level, such
# as "sdxl-turbo/vae", nor do we want to put the contents of the vae folder directly into "sdxl-turbo".
# So what we do is to synthesize a folder named "sdxl-turbo_vae" here.
- if subfolder:
+ #
+ # For multiple subfolders (e.g., text_encoder+tokenizer), we create a combined folder name
+ # (e.g., sdxl-turbo_text_encoder_tokenizer) and keep each subfolder's contents in its own
+ # subdirectory within the model folder.
+
+ if subfolders and len(subfolders) > 1:
+ # Multiple subfolders: create combined name and keep subfolder structure
+ top = Path(remote_files[0].path.parts[0]) # e.g. "Z-Image-Turbo/"
+ subfolder_names = [sf.name.replace("/", "_").replace("\\", "_") for sf in subfolders]
+ combined_name = "_".join(subfolder_names)
+ path_to_add = Path(f"{top}_{combined_name}")
+
+ parts: List[RemoteModelFile] = []
+ for model_file in remote_files:
+ assert model_file.size is not None
+ # Determine which subfolder this file belongs to
+ file_path = model_file.path
+ new_path: Optional[Path] = None
+ for sf in subfolders:
+ try:
+ # Try to get relative path from this subfolder
+ relative = file_path.relative_to(top / sf)
+ # Keep the subfolder name as a subdirectory
+ new_path = path_to_add / sf.name / relative
+ break
+ except ValueError:
+ continue
+
+ if new_path is None:
+ # File doesn't match any subfolder, keep original path structure
+ new_path = path_to_add / file_path.relative_to(top)
+
+ parts.append(RemoteModelFile(url=model_file.url, path=new_path))
+ elif subfolder:
+ # Single subfolder: flatten into renamed folder
top = Path(remote_files[0].path.parts[0]) # e.g. "sdxl-turbo/"
- path_to_remove = top / subfolder.parts[-1] # sdxl-turbo/vae/
- path_to_add = Path(f"{top}_{subfolder}")
- else:
- path_to_remove = Path(".")
- path_to_add = Path(".")
-
- parts: List[RemoteModelFile] = []
- for model_file in remote_files:
- assert model_file.size is not None
- parts.append(
- RemoteModelFile(
- url=model_file.url, # if a subfolder, then sdxl-turbo_vae/config.json
- path=path_to_add / model_file.path.relative_to(path_to_remove),
+ path_to_remove = top / subfolder # sdxl-turbo/vae/
+ subfolder_rename = subfolder.name.replace("/", "_").replace("\\", "_")
+ path_to_add = Path(f"{top}_{subfolder_rename}")
+
+ parts = []
+ for model_file in remote_files:
+ assert model_file.size is not None
+ parts.append(
+ RemoteModelFile(
+ url=model_file.url,
+ path=path_to_add / model_file.path.relative_to(path_to_remove),
+ )
)
- )
+ else:
+ # No subfolder specified - pass through unchanged
+ parts = []
+ for model_file in remote_files:
+ assert model_file.size is not None
+ parts.append(RemoteModelFile(url=model_file.url, path=model_file.path))
return self._download_queue.multifile_download(
parts=parts,
@@ -821,7 +1395,9 @@ def _download_started_callback(self, download_job: MultiFileDownloadJob) -> None
install_job.local_path = download_job.download_path
install_job.download_parts = download_job.download_parts
install_job.bytes = sum(x.bytes for x in download_job.download_parts)
- install_job.total_bytes = download_job.total_bytes
+ total_parts = sum(x.total_bytes for x in download_job.download_parts)
+ if total_parts > 0:
+ install_job.total_bytes = max(install_job.total_bytes or 0, total_parts)
self._signal_job_download_started(install_job)
def _download_progress_callback(self, download_job: MultiFileDownloadJob) -> None:
@@ -832,7 +1408,9 @@ def _download_progress_callback(self, download_job: MultiFileDownloadJob) -> Non
else:
# update sizes
install_job.bytes = sum(x.bytes for x in download_job.download_parts)
- install_job.total_bytes = sum(x.total_bytes for x in download_job.download_parts)
+ total_parts = sum(x.total_bytes for x in download_job.download_parts)
+ if total_parts > 0:
+ install_job.total_bytes = max(install_job.total_bytes or 0, total_parts)
self._signal_job_downloading(install_job)
def _download_complete_callback(self, download_job: MultiFileDownloadJob) -> None:
@@ -848,8 +1426,10 @@ def _download_error_callback(self, download_job: MultiFileDownloadJob, excp: Opt
with self._lock:
if install_job := self._download_cache.pop(download_job.id, None):
assert excp is not None
- install_job.set_error(excp)
+ self._set_error(install_job, excp)
self._download_queue.cancel_job(download_job)
+ if install_job._install_tmpdir is not None:
+ self._safe_rmtree(install_job._install_tmpdir, self._logger)
# Let other threads know that the number of downloads has changed
self._downloads_changed_event.set()
@@ -858,9 +1438,19 @@ def _download_cancelled_callback(self, download_job: MultiFileDownloadJob) -> No
with self._lock:
if install_job := self._download_cache.pop(download_job.id, None):
self._downloads_changed_event.set()
+ if any(part.resume_required for part in download_job.download_parts):
+ install_job.status = InstallStatus.PAUSED
+ self._write_install_marker(install_job, status=InstallStatus.PAUSED)
+ self._downloads_changed_event.set()
+ return
# if install job has already registered an error, then do not replace its status with cancelled
- if not install_job.errored:
+ if not install_job.errored and not install_job.paused:
install_job.cancel()
+ if install_job._install_tmpdir is not None:
+ # Mark cancelled before cleanup so we don't reuse the folder if deletion fails.
+ self._write_install_marker(install_job, status=InstallStatus.CANCELLED)
+ self._delete_install_marker(install_job._install_tmpdir)
+ self._safe_rmtree(install_job._install_tmpdir, self._logger)
# Let other threads know that the number of downloads has changed
self._downloads_changed_event.set()
@@ -871,6 +1461,7 @@ def _download_cancelled_callback(self, download_job: MultiFileDownloadJob) -> No
def _signal_job_running(self, job: ModelInstallJob) -> None:
job.status = InstallStatus.RUNNING
self._logger.info(f"Model install started: {job.source}")
+ self._write_install_marker(job, status=InstallStatus.RUNNING)
if self._event_bus:
self._event_bus.emit_model_install_started(job)
@@ -880,6 +1471,7 @@ def _signal_job_download_started(self, job: ModelInstallJob) -> None:
assert job.bytes is not None
assert job.total_bytes is not None
self._event_bus.emit_model_install_download_started(job)
+ self._write_install_marker(job, status=InstallStatus.DOWNLOADING)
def _signal_job_downloading(self, job: ModelInstallJob) -> None:
if self._event_bus:
@@ -891,6 +1483,7 @@ def _signal_job_downloading(self, job: ModelInstallJob) -> None:
def _signal_job_downloads_done(self, job: ModelInstallJob) -> None:
job.status = InstallStatus.DOWNLOADS_DONE
self._logger.info(f"Model download complete: {job.source}")
+ self._write_install_marker(job, status=InstallStatus.DOWNLOADS_DONE)
if self._event_bus:
self._event_bus.emit_model_install_downloads_complete(job)
@@ -899,6 +1492,8 @@ def _signal_job_completed(self, job: ModelInstallJob) -> None:
assert job.config_out
self._logger.info(f"Model install complete: {job.source}")
self._logger.debug(f"{job.local_path} registered key {job.config_out.key}")
+ if job._install_tmpdir is not None:
+ self._delete_install_marker(job._install_tmpdir)
if self._event_bus:
assert job.local_path is not None
assert job.config_out is not None
@@ -906,6 +1501,8 @@ def _signal_job_completed(self, job: ModelInstallJob) -> None:
def _signal_job_errored(self, job: ModelInstallJob) -> None:
self._logger.error(f"Model install error: {job.source}\n{job.error_type}: {job.error}")
+ if job._install_tmpdir is not None:
+ self._delete_install_marker(job._install_tmpdir)
if self._event_bus:
assert job.error_type is not None
assert job.error is not None
@@ -913,6 +1510,8 @@ def _signal_job_errored(self, job: ModelInstallJob) -> None:
def _signal_job_cancelled(self, job: ModelInstallJob) -> None:
self._logger.info(f"Model install canceled: {job.source}")
+ if job._install_tmpdir is not None:
+ self._delete_install_marker(job._install_tmpdir)
if self._event_bus:
self._event_bus.emit_model_install_cancelled(job)
@@ -927,3 +1526,15 @@ def get_fetcher_from_url(url: str) -> Type[ModelMetadataFetchBase]:
if re.match(r"^https?://huggingface.co/[^/]+/[^/]+$", url.lower()):
return HuggingFaceMetadataFetch
raise ValueError(f"Unsupported model source: '{url}'")
+
+ @staticmethod
+ def _normalize_huggingface_blob_url(url: AnyHttpUrl) -> Url:
+ """Convert Hugging Face file page URLs to direct download URLs."""
+ return Url(
+ re.sub(
+ r"^(https?://huggingface\.co/[^/]+/[^/]+)/blob/([^?#]+)([?#].*)?$",
+ r"\1/resolve/\2\3",
+ str(url),
+ flags=re.IGNORECASE,
+ )
+ )
diff --git a/invokeai/app/services/model_load/__init__.py b/invokeai/app/services/model_load/__init__.py
index b4a86e9348d..4c7e40c8c76 100644
--- a/invokeai/app/services/model_load/__init__.py
+++ b/invokeai/app/services/model_load/__init__.py
@@ -1,6 +1,6 @@
"""Initialization file for model load service module."""
-from .model_load_base import ModelLoadServiceBase
-from .model_load_default import ModelLoadService
+from invokeai.app.services.model_load.model_load_base import ModelLoadServiceBase
+from invokeai.app.services.model_load.model_load_default import ModelLoadService
__all__ = ["ModelLoadServiceBase", "ModelLoadService"]
diff --git a/invokeai/app/services/model_load/model_load_base.py b/invokeai/app/services/model_load/model_load_base.py
index da567721956..8fc9823328d 100644
--- a/invokeai/app/services/model_load/model_load_base.py
+++ b/invokeai/app/services/model_load/model_load_base.py
@@ -5,10 +5,10 @@
from pathlib import Path
from typing import Callable, Optional
-from invokeai.backend.model_manager import AnyModel, AnyModelConfig, SubModelType
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.load import LoadedModel, LoadedModelWithoutConfig
-from invokeai.backend.model_manager.load.convert_cache import ModelConvertCacheBase
-from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase
+from invokeai.backend.model_manager.load.model_cache.model_cache import ModelCache
+from invokeai.backend.model_manager.taxonomy import AnyModel, SubModelType
class ModelLoadServiceBase(ABC):
@@ -25,13 +25,22 @@ def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubMo
@property
@abstractmethod
- def ram_cache(self) -> ModelCacheBase[AnyModel]:
- """Return the RAM cache used by this loader."""
+ def ram_cache(self) -> ModelCache:
+ """Return the RAM cache for the current thread's execution device.
+
+ In multi-GPU mode, each session-processor worker is pinned to a device and gets its own
+ cache; this resolves to the calling thread's cache. Outside a worker (e.g. API threads),
+ it resolves to the default device's cache.
+ """
@property
@abstractmethod
- def convert_cache(self) -> ModelConvertCacheBase:
- """Return the checkpoint convert cache used by this loader."""
+ def ram_caches(self) -> dict[str, ModelCache]:
+ """Return all per-device RAM caches, keyed by normalized device string.
+
+ Use this for maintenance operations that must apply to every device (clear cache, drop a
+ model from all devices, shutdown).
+ """
@abstractmethod
def load_model_from_path(
diff --git a/invokeai/app/services/model_load/model_load_default.py b/invokeai/app/services/model_load/model_load_default.py
index 70674813785..33c7ef6108c 100644
--- a/invokeai/app/services/model_load/model_load_default.py
+++ b/invokeai/app/services/model_load/model_load_default.py
@@ -10,21 +10,20 @@
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.invoker import Invoker
-from invokeai.backend.model_manager import AnyModel, AnyModelConfig, SubModelType
+from invokeai.app.services.model_load.model_load_base import ModelLoadServiceBase
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.load import (
LoadedModel,
LoadedModelWithoutConfig,
ModelLoaderRegistry,
ModelLoaderRegistryBase,
)
-from invokeai.backend.model_manager.load.convert_cache import ModelConvertCacheBase
-from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase
+from invokeai.backend.model_manager.load.model_cache.model_cache import MODEL_LOAD_LOCK, ModelCache
from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader
+from invokeai.backend.model_manager.taxonomy import AnyModel, SubModelType
from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.logging import InvokeAILogger
-from .model_load_base import ModelLoadServiceBase
-
class ModelLoadService(ModelLoadServiceBase):
"""Wrapper around ModelLoaderRegistry."""
@@ -32,31 +31,46 @@ class ModelLoadService(ModelLoadServiceBase):
def __init__(
self,
app_config: InvokeAIAppConfig,
- ram_cache: ModelCacheBase[AnyModel],
- convert_cache: ModelConvertCacheBase,
+ ram_cache: ModelCache,
registry: Optional[Type[ModelLoaderRegistryBase]] = ModelLoaderRegistry,
+ ram_caches: Optional[dict[str, ModelCache]] = None,
):
- """Initialize the model load service."""
+ """Initialize the model load service.
+
+ Args:
+ ram_cache: The default RAM cache, used when no per-device cache matches the calling
+ thread (e.g. single-device installs, or API threads).
+ ram_caches: Optional map of normalized device string -> ModelCache for multi-GPU mode.
+ One cache per generation device. The default `ram_cache` is always included.
+ """
logger = InvokeAILogger.get_logger(self.__class__.__name__)
logger.setLevel(app_config.log_level.upper())
self._logger = logger
self._app_config = app_config
- self._ram_cache = ram_cache
- self._convert_cache = convert_cache
+ self._default_ram_cache = ram_cache
+ # Map normalized device string -> cache. Always includes the default cache so that callers
+ # without a pinned device (API threads) resolve to a valid cache.
+ self._ram_caches: dict[str, ModelCache] = dict(ram_caches) if ram_caches else {}
+ self._ram_caches.setdefault(str(TorchDevice.normalize(ram_cache.execution_device)), ram_cache)
self._registry = registry
def start(self, invoker: Invoker) -> None:
self._invoker = invoker
@property
- def ram_cache(self) -> ModelCacheBase[AnyModel]:
- """Return the RAM cache used by this loader."""
- return self._ram_cache
+ def ram_cache(self) -> ModelCache:
+ """Return the RAM cache for the calling thread's execution device.
+
+ `choose_torch_device()` is thread-local-aware: a session-processor worker pinned to a GPU
+ gets that GPU's cache; everything else falls back to the default cache.
+ """
+ key = str(TorchDevice.choose_torch_device())
+ return self._ram_caches.get(key, self._default_ram_cache)
@property
- def convert_cache(self) -> ModelConvertCacheBase:
- """Return the checkpoint convert cache used by this loader."""
- return self._convert_cache
+ def ram_caches(self) -> dict[str, ModelCache]:
+ """Return all per-device RAM caches, keyed by normalized device string."""
+ return dict(self._ram_caches)
def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> LoadedModel:
"""
@@ -75,8 +89,7 @@ def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubMo
loaded_model: LoadedModel = implementation(
app_config=self._app_config,
logger=self._logger,
- ram_cache=self._ram_cache,
- convert_cache=self._convert_cache,
+ ram_cache=self.ram_cache,
).load_model(model_config, submodel_type)
if hasattr(self, "_invoker"):
@@ -87,17 +100,33 @@ def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubMo
def load_model_from_path(
self, model_path: Path, loader: Optional[Callable[[Path], AnyModel]] = None
) -> LoadedModelWithoutConfig:
- cache_key = str(model_path)
+ # Resolve the calling thread's cache once so the whole load uses a single device's cache.
ram_cache = self.ram_cache
+ cache_key = str(model_path)
try:
- return LoadedModelWithoutConfig(_locker=ram_cache.get(key=cache_key))
+ return LoadedModelWithoutConfig(cache_record=ram_cache.get(key=cache_key), cache=ram_cache)
except IndexError:
pass
def torch_load_file(checkpoint: Path) -> AnyModel:
scan_result = scan_file_path(checkpoint)
if scan_result.infected_files != 0:
- raise Exception("The model at {checkpoint} is potentially infected by malware. Aborting load.")
+ if self._app_config.unsafe_disable_picklescan:
+ self._logger.warning(
+ f"Model at {checkpoint} is potentially infected by malware, but picklescan is disabled. "
+ "Proceeding with caution."
+ )
+ else:
+ raise Exception(f"The model at {checkpoint} is potentially infected by malware. Aborting load.")
+ if scan_result.scan_err:
+ if self._app_config.unsafe_disable_picklescan:
+ self._logger.warning(
+ f"Error scanning model at {checkpoint} for malware, but picklescan is disabled. "
+ "Proceeding with caution."
+ )
+ else:
+ raise Exception(f"Error scanning model at {checkpoint} for malware. Aborting load.")
+
result = torch_load(checkpoint, map_location="cpu")
return result
@@ -105,7 +134,7 @@ def diffusers_load_directory(directory: Path) -> AnyModel:
load_class = GenericDiffusersLoader(
app_config=self._app_config,
logger=self._logger,
- ram_cache=self._ram_cache,
+ ram_cache=ram_cache,
convert_cache=self.convert_cache,
).get_hf_load_class(directory)
return load_class.from_pretrained(model_path, torch_dtype=TorchDevice.choose_torch_dtype())
@@ -118,6 +147,15 @@ def diffusers_load_directory(directory: Path) -> AnyModel:
else lambda path: safetensors_load_file(path, device="cpu")
)
assert loader is not None
- raw_model = loader(model_path)
- ram_cache.put(key=cache_key, model=raw_model)
- return LoadedModelWithoutConfig(_locker=ram_cache.get(key=cache_key))
+ # Serialize construction (see MODEL_LOAD_LOCK): the diffusers loader path uses the same
+ # process-global, non-thread-safe monkey-patches as the main loader, so it takes the write
+ # lock to exclude concurrent VRAM moves. Re-check the cache after acquiring the lock in case
+ # a worker sharing this cache built it while we waited.
+ with MODEL_LOAD_LOCK.write_lock():
+ try:
+ return LoadedModelWithoutConfig(cache_record=ram_cache.get(key=cache_key), cache=ram_cache)
+ except IndexError:
+ pass
+ raw_model = loader(model_path)
+ ram_cache.put(key=cache_key, model=raw_model)
+ return LoadedModelWithoutConfig(cache_record=ram_cache.get(key=cache_key), cache=ram_cache)
diff --git a/invokeai/app/services/model_manager/__init__.py b/invokeai/app/services/model_manager/__init__.py
index 5455577266a..e703d4f1ffc 100644
--- a/invokeai/app/services/model_manager/__init__.py
+++ b/invokeai/app/services/model_manager/__init__.py
@@ -1,17 +1,10 @@
"""Initialization file for model manager service."""
-from invokeai.backend.model_manager import AnyModel, AnyModelConfig, BaseModelType, ModelType, SubModelType
+from invokeai.app.services.model_manager.model_manager_default import ModelManagerService, ModelManagerServiceBase
from invokeai.backend.model_manager.load import LoadedModel
-from .model_manager_default import ModelManagerService, ModelManagerServiceBase
-
__all__ = [
"ModelManagerServiceBase",
"ModelManagerService",
- "AnyModel",
- "AnyModelConfig",
- "BaseModelType",
- "ModelType",
- "SubModelType",
"LoadedModel",
]
diff --git a/invokeai/app/services/model_manager/model_manager_base.py b/invokeai/app/services/model_manager/model_manager_base.py
index af1b68e1ec3..a906076b163 100644
--- a/invokeai/app/services/model_manager/model_manager_base.py
+++ b/invokeai/app/services/model_manager/model_manager_base.py
@@ -5,14 +5,13 @@
import torch
from typing_extensions import Self
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.download.download_base import DownloadQueueServiceBase
+from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.invoker import Invoker
-
-from ..config import InvokeAIAppConfig
-from ..download import DownloadQueueServiceBase
-from ..events.events_base import EventServiceBase
-from ..model_install import ModelInstallServiceBase
-from ..model_load import ModelLoadServiceBase
-from ..model_records import ModelRecordServiceBase
+from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase
+from invokeai.app.services.model_load.model_load_base import ModelLoadServiceBase
+from invokeai.app.services.model_records.model_records_base import ModelRecordServiceBase
class ModelManagerServiceBase(ABC):
diff --git a/invokeai/app/services/model_manager/model_manager_default.py b/invokeai/app/services/model_manager/model_manager_default.py
index 1a2b9a34022..176b61ddcab 100644
--- a/invokeai/app/services/model_manager/model_manager_default.py
+++ b/invokeai/app/services/model_manager/model_manager_default.py
@@ -6,19 +6,23 @@
import torch
from typing_extensions import Self
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.download.download_base import DownloadQueueServiceBase
+from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.invoker import Invoker
-from invokeai.backend.model_manager.load import ModelCache, ModelConvertCache, ModelLoaderRegistry
+from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase
+from invokeai.app.services.model_install.model_install_default import ModelInstallService
+from invokeai.app.services.model_load.model_load_base import ModelLoadServiceBase
+from invokeai.app.services.model_load.model_load_default import ModelLoadService
+from invokeai.app.services.model_manager.model_manager_base import ModelManagerServiceBase
+from invokeai.app.services.model_records.model_records_base import ModelRecordServiceBase
+from invokeai.backend.model_manager.load.model_cache.model_cache import ModelCache
+from invokeai.backend.model_manager.load.model_cache.ram_budget import RamBudget
+from invokeai.backend.model_manager.load.model_cache.shared_cpu_weights import SharedCpuWeightsStore
+from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry
from invokeai.backend.util.devices import TorchDevice
from invokeai.backend.util.logging import InvokeAILogger
-from ..config import InvokeAIAppConfig
-from ..download import DownloadQueueServiceBase
-from ..events.events_base import EventServiceBase
-from ..model_install import ModelInstallService, ModelInstallServiceBase
-from ..model_load import ModelLoadService, ModelLoadServiceBase
-from ..model_records import ModelRecordServiceBase
-from .model_manager_base import ModelManagerServiceBase
-
class ModelManagerService(ModelManagerServiceBase):
"""
@@ -58,6 +62,11 @@ def start(self, invoker: Invoker) -> None:
service.start(invoker)
def stop(self, invoker: Invoker) -> None:
+ # Shutdown every per-device model cache to cancel any pending keep-alive timers.
+ if hasattr(self._load, "ram_caches"):
+ for cache in self._load.ram_caches.values():
+ cache.shutdown()
+
for service in [self._store, self._install, self._load]:
if hasattr(service, "stop"):
service.stop(invoker)
@@ -79,19 +88,76 @@ def build_model_manager(
logger = InvokeAILogger.get_logger(cls.__name__)
logger.setLevel(app_config.log_level.upper())
- ram_cache = ModelCache(
- max_cache_size=app_config.ram,
- max_vram_cache_size=app_config.vram,
- lazy_offloading=app_config.lazy_offload,
- logger=logger,
- execution_device=execution_device or TorchDevice.choose_torch_device(),
+ # One store + budget shared by every per-device cache. The store deduplicates each model's CPU
+ # weights to a single copy across GPUs (see SharedCpuWeightsStore); the budget is the single
+ # system-wide RAM authority so per-device caches stop double-counting shared weights when they
+ # decide what to evict (see RamBudget).
+ shared_store = SharedCpuWeightsStore()
+
+ def build_cache(device: torch.device) -> ModelCache:
+ return ModelCache(
+ execution_device_working_mem_gb=app_config.device_working_mem_gb,
+ enable_partial_loading=app_config.enable_partial_loading,
+ keep_ram_copy_of_weights=app_config.keep_ram_copy_of_weights,
+ max_ram_cache_size_gb=app_config.max_cache_ram_gb,
+ max_vram_cache_size_gb=app_config.max_cache_vram_gb,
+ execution_device=device,
+ storage_device="cpu",
+ log_memory_usage=app_config.log_memory_usage,
+ logger=logger,
+ keep_alive_minutes=app_config.model_cache_keep_alive_min,
+ shared_cpu_weights=shared_store,
+ )
+
+ # The default cache for callers without a pinned device (API threads, single-device installs).
+ default_device = execution_device or TorchDevice.choose_torch_device()
+ ram_cache = build_cache(default_device)
+
+ # In multi-GPU mode, build one independent cache per generation device. Each session-processor
+ # worker is pinned to a device (see TorchDevice.set_session_device) and resolves to its own
+ # cache. The default cache is always included by ModelLoadService.
+ ram_caches: dict[str, ModelCache] = {str(TorchDevice.normalize(default_device)): ram_cache}
+ for device in TorchDevice.get_generation_devices(app_config.generation_devices):
+ key = str(device)
+ if key not in ram_caches:
+ ram_caches[key] = build_cache(device)
+
+ # Attach the single global RAM budget. The cap is the user's max_cache_ram_gb interpreted as a
+ # true system-wide limit; when unset, it is the sum of the caches' individually-calculated
+ # sizes, so each device keeps its effective capacity and weight deduplication becomes headroom.
+ # That sum is then clamped to a safe fraction of system RAM: each per-device heuristic already
+ # allows up to ~half of system RAM, so summing across N GPUs would otherwise claim ~N× that and
+ # leave nothing for the OS, causing swap thrashing. The clamp leaves real headroom; shared-weight
+ # dedup means the true footprint usually stays well under the cap regardless.
+ gb = 2**30
+ distinct_caches = list(dict.fromkeys(ram_caches.values()))
+ # Cross-device weight adoption (and its per-model meta-shell capture) only pays off with more
+ # than one device cache; disable the capture cost otherwise.
+ shared_store.enable_shell_capture = len(distinct_caches) > 1
+ if app_config.max_cache_ram_gb is not None:
+ global_ram_budget_bytes = int(app_config.max_cache_ram_gb * gb)
+ else:
+ summed_cache_bytes = sum(c.local_ram_cache_size_bytes for c in distinct_caches)
+ system_ram_headroom_bytes = ModelCache.calc_system_ram_headroom_bytes()
+ global_ram_budget_bytes = min(summed_cache_bytes, system_ram_headroom_bytes)
+ if global_ram_budget_bytes < summed_cache_bytes:
+ logger.info(
+ f"Capping model cache RAM budget at {global_ram_budget_bytes / gb:.2f} GB to leave system "
+ f"headroom (sum of per-device caches was {summed_cache_bytes / gb:.2f} GB)."
+ )
+ ram_budget = RamBudget(max_bytes=global_ram_budget_bytes, shared_store=shared_store)
+ for cache in distinct_caches:
+ cache.set_ram_budget(ram_budget)
+ logger.info(
+ f"Model cache global RAM budget: {global_ram_budget_bytes / gb:.2f} GB "
+ f"across {len(distinct_caches)} device cache(s)."
)
- convert_cache = ModelConvertCache(cache_path=app_config.convert_cache_path, max_size=app_config.convert_cache)
+
loader = ModelLoadService(
app_config=app_config,
ram_cache=ram_cache,
- convert_cache=convert_cache,
registry=ModelLoaderRegistry,
+ ram_caches=ram_caches,
)
installer = ModelInstallService(
app_config=app_config,
diff --git a/invokeai/app/services/model_records/model_records_base.py b/invokeai/app/services/model_records/model_records_base.py
index 57531cf3c19..e06f8f2df91 100644
--- a/invokeai/app/services/model_records/model_records_base.py
+++ b/invokeai/app/services/model_records/model_records_base.py
@@ -6,21 +6,34 @@
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path
-from typing import List, Optional, Set, Union
+from typing import Any, List, Optional, Set, Union
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from invokeai.app.services.shared.pagination import PaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
-from invokeai.backend.model_manager.config import (
- AnyModelConfig,
+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
+from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
- ControlAdapterDefaultSettings,
- MainModelDefaultSettings,
+ ClipVariantType,
+ Flux2VariantType,
+ FluxVariantType,
ModelFormat,
+ ModelSourceType,
ModelType,
ModelVariantType,
+ Qwen3VariantType,
+ QwenImageVariantType,
SchedulerPredictionType,
+ ZImageVariantType,
)
@@ -48,6 +61,10 @@ class ModelRecordOrderBy(str, Enum):
Base = "base"
Name = "name"
Format = "format"
+ Size = "size"
+ DateAdded = "created_at"
+ DateModified = "updated_at"
+ Path = "path"
class ModelSummary(BaseModel):
@@ -66,18 +83,59 @@ class ModelRecordChanges(BaseModelExcludeNull):
"""A set of changes to apply to a model."""
# Changes applicable to all models
+ source: Optional[str] = Field(description="original source of the model", default=None)
+ source_type: Optional[ModelSourceType] = Field(description="type of model source", default=None)
+ source_api_response: Optional[str] = Field(description="metadata from remote source", default=None)
+ source_url: Optional[str] = Field(description="Optional URL for the model (e.g. download page)", default=None)
+
+ @field_validator("source_url", mode="before")
+ @classmethod
+ def validate_source_url(cls, v: Any) -> Optional[str]:
+ if v is None or v == "":
+ return None
+ if not isinstance(v, str):
+ raise ValueError("source_url must be a string")
+ if not v.startswith(("https://", "http://")):
+ raise ValueError("source_url must be an http or https URL")
+ return v
+
name: Optional[str] = Field(description="Name of the model.", default=None)
path: Optional[str] = Field(description="Path to the model.", default=None)
description: Optional[str] = Field(description="Model description", default=None)
base: Optional[BaseModelType] = Field(description="The base model.", default=None)
+ type: Optional[ModelType] = Field(description="Type of model", default=None)
+ key: Optional[str] = Field(description="Database ID for this model", default=None)
+ hash: Optional[str] = Field(description="hash of model file", default=None)
+ 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 | 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)
# Checkpoint-specific changes
# TODO(MM2): Should we expose these? Feels footgun-y...
- variant: Optional[ModelVariantType] = Field(description="The variant of the model.", default=None)
+ variant: Optional[
+ ModelVariantType
+ | ClipVariantType
+ | FluxVariantType
+ | Flux2VariantType
+ | ZImageVariantType
+ | QwenImageVariantType
+ | Qwen3VariantType
+ ] = Field(description="The variant of the model.", default=None)
prediction_type: Optional[SchedulerPredictionType] = Field(
description="The prediction type of the model.", default=None
)
@@ -113,12 +171,26 @@ def del_model(self, key: str) -> None:
pass
@abstractmethod
- def update_model(self, key: str, changes: ModelRecordChanges) -> AnyModelConfig:
+ def update_model(self, key: str, changes: ModelRecordChanges, allow_class_change: bool = False) -> AnyModelConfig:
"""
Update the model, returning the updated version.
:param key: Unique key for the model to be updated.
:param changes: A set of changes to apply to this model. Changes are validated before being written.
+ :param allow_class_change: If True, allows changes that would change the model config class. For example,
+ changing a LoRA into a Main model. This does not disable validation, so the changes must still be valid.
+ """
+ pass
+
+ @abstractmethod
+ def replace_model(self, key: str, new_config: AnyModelConfig) -> AnyModelConfig:
+ """
+ Replace the model record entirely, returning the new record.
+
+ This is used when we re-identify a model and have a new config object.
+
+ :param key: Unique key for the model to be updated.
+ :param new_config: The new model config to write.
"""
pass
@@ -146,7 +218,11 @@ def get_model_by_hash(self, hash: str) -> AnyModelConfig:
@abstractmethod
def list_models(
- self, page: int = 0, per_page: int = 10, order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default
+ self,
+ page: int = 0,
+ per_page: int = 10,
+ order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default,
+ direction: SQLiteDirection = SQLiteDirection.Ascending,
) -> PaginatedResults[ModelSummary]:
"""Return a paginated summary listing of each model in the database."""
pass
@@ -183,6 +259,8 @@ def search_by_attr(
base_model: Optional[BaseModelType] = None,
model_type: Optional[ModelType] = None,
model_format: Optional[ModelFormat] = None,
+ order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default,
+ direction: SQLiteDirection = SQLiteDirection.Ascending,
) -> List[AnyModelConfig]:
"""
Return models matching name, base and/or type.
diff --git a/invokeai/app/services/model_records/model_records_sql.py b/invokeai/app/services/model_records/model_records_sql.py
index 16abf4c523c..3452ba44316 100644
--- a/invokeai/app/services/model_records/model_records_sql.py
+++ b/invokeai/app/services/model_records/model_records_sql.py
@@ -40,22 +40,16 @@
"""
import json
+import logging
import sqlite3
from math import ceil
from pathlib import Path
from typing import List, Optional, Union
-from invokeai.app.services.shared.pagination import PaginatedResults
-from invokeai.backend.model_manager.config import (
- AnyModelConfig,
- BaseModelType,
- ModelConfigFactory,
- ModelFormat,
- ModelType,
-)
+import pydantic
+from pydantic import ValidationError
-from ..shared.sqlite.sqlite_database import SqliteDatabase
-from .model_records_base import (
+from invokeai.app.services.model_records.model_records_base import (
DuplicateModelException,
ModelRecordChanges,
ModelRecordOrderBy,
@@ -63,12 +57,42 @@
ModelSummary,
UnknownModelException,
)
+from invokeai.app.services.shared.pagination import PaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.backend.model_manager.configs.base import Config_Base
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig, ModelConfigFactory
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
+
+
+def _construct_config_for_type(fields: dict, target_type: ModelType) -> AnyModelConfig:
+ """Try every config class whose `type` default matches `target_type` and return the first that validates.
+
+ Used when changing a model's type via the update endpoint: the existing record's `format`/`variant`
+ fields belong to the old class and may not have a discriminator match in the new type space, so we
+ fall back to constructing each candidate class directly with whatever fields it accepts.
+ """
+ last_error: Exception | None = None
+ for candidate_class in Config_Base.CONFIG_CLASSES:
+ type_field = candidate_class.model_fields.get("type")
+ if type_field is None or type_field.default != target_type:
+ continue
+ try:
+ return candidate_class(**fields) # type: ignore[return-value]
+ except ValidationError as e:
+ last_error = e
+ if last_error is not None:
+ raise last_error
+ raise ValidationError.from_exception_data(
+ f"No model config class found for type={target_type!r}",
+ line_errors=[],
+ )
class ModelRecordServiceSQL(ModelRecordServiceBase):
"""Implementation of the ModelConfigStore ABC using a SQL database."""
- def __init__(self, db: SqliteDatabase):
+ def __init__(self, db: SqliteDatabase, logger: logging.Logger):
"""
Initialize a new object from preexisting sqlite3 connection and threading lock objects.
@@ -76,12 +100,7 @@ def __init__(self, db: SqliteDatabase):
"""
super().__init__()
self._db = db
- self._cursor = db.conn.cursor()
-
- @property
- def db(self) -> SqliteDatabase:
- """Return the underlying database."""
- return self._db
+ self._logger = logger
def add_model(self, config: AnyModelConfig) -> AnyModelConfig:
"""
@@ -93,14 +112,14 @@ def add_model(self, config: AnyModelConfig) -> AnyModelConfig:
Can raise DuplicateModelException and InvalidModelConfigException exceptions.
"""
- with self._db.lock:
+ with self._db.transaction() as cursor:
try:
- self._cursor.execute(
+ cursor.execute(
"""--sql
INSERT INTO models (
- id,
- config
- )
+ id,
+ config
+ )
VALUES (?,?);
""",
(
@@ -108,10 +127,8 @@ def add_model(self, config: AnyModelConfig) -> AnyModelConfig:
config.model_dump_json(),
),
)
- self._db.conn.commit()
except sqlite3.IntegrityError as e:
- self._db.conn.rollback()
if "UNIQUE constraint failed" in str(e):
if "models.path" in str(e):
msg = f"A model with path '{config.path}' is already installed"
@@ -122,9 +139,6 @@ def add_model(self, config: AnyModelConfig) -> AnyModelConfig:
raise DuplicateModelException(msg) from e
else:
raise e
- except sqlite3.Error as e:
- self._db.conn.rollback()
- raise e
return self.get_model(config.key)
@@ -136,49 +150,93 @@ def del_model(self, key: str) -> None:
Can raise an UnknownModelException
"""
- with self._db.lock:
- try:
- self._cursor.execute(
- """--sql
- DELETE FROM models
- WHERE id=?;
- """,
- (key,),
- )
- if self._cursor.rowcount == 0:
- raise UnknownModelException("model not found")
- self._db.conn.commit()
- except sqlite3.Error as e:
- self._db.conn.rollback()
- raise e
-
- def update_model(self, key: str, changes: ModelRecordChanges) -> AnyModelConfig:
- record = self.get_model(key)
-
- # Model configs use pydantic's `validate_assignment`, so each change is validated by pydantic.
- for field_name in changes.model_fields_set:
- setattr(record, field_name, getattr(changes, field_name))
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ DELETE FROM models
+ WHERE id=?;
+ """,
+ (key,),
+ )
+ if cursor.rowcount == 0:
+ raise UnknownModelException("model not found")
- json_serialized = record.model_dump_json()
+ def update_model(self, key: str, changes: ModelRecordChanges, allow_class_change: bool = False) -> AnyModelConfig:
+ with self._db.transaction() as cursor:
+ record = self.get_model(key)
+
+ if allow_class_change:
+ # The changes may cause the model config class to change. To handle this, we need to construct the new
+ # class from scratch rather than trying to modify the existing instance in place.
+ #
+ # 1. Convert the existing record to a dict
+ # 2. Apply the changes to the dict
+ # 3. Attempt to create a new model config from the updated dict
+
+ # 1. Convert the existing record to a dict
+ record_as_dict = record.model_dump()
+
+ # 2. Apply the changes to the dict
+ for field_name in changes.model_fields_set:
+ record_as_dict[field_name] = getattr(changes, field_name)
+
+ # 3. Attempt to create a new model config from the updated dict.
+ #
+ # When the model type is being changed, the previous record's `format` and `variant` likely
+ # belong to the old config class and won't validate against the new one (e.g. switching a
+ # Qwen3 encoder to a Text LLM keeps format=qwen3_encoder, which has no matching discriminator
+ # under text_llm). If the initial validation fails and the type changed, retry with stale
+ # format/variant fields stripped so the new class can apply its own defaults.
+ type_changed = "type" in changes.model_fields_set and changes.type != record.type
+ try:
+ record = ModelConfigFactory.from_dict(record_as_dict)
+ except ValidationError:
+ if not type_changed:
+ raise
+ fallback_dict = dict(record_as_dict)
+ for stale_field in ("format", "variant"):
+ if stale_field not in changes.model_fields_set:
+ fallback_dict.pop(stale_field, None)
+ record = _construct_config_for_type(fallback_dict, changes.type)
+
+ # If we get this far, the updated model config is valid, so we can save it to the database.
+ json_serialized = record.model_dump_json()
+ else:
+ # We are not allowing the model config class to change, so we can just update the existing instance in
+ # place. If the changes are invalid for the existing class, an exception will be raised by pydantic.
+ for field_name in changes.model_fields_set:
+ setattr(record, field_name, getattr(changes, field_name))
+ json_serialized = record.model_dump_json()
+
+ cursor.execute(
+ """--sql
+ UPDATE models
+ SET
+ config=?
+ WHERE id=?;
+ """,
+ (json_serialized, key),
+ )
+ if cursor.rowcount == 0:
+ raise UnknownModelException("model not found")
- with self._db.lock:
- try:
- self._cursor.execute(
- """--sql
- UPDATE models
- SET
- config=?
- WHERE id=?;
- """,
- (json_serialized, key),
- )
- if self._cursor.rowcount == 0:
- raise UnknownModelException("model not found")
- self._db.conn.commit()
- except sqlite3.Error as e:
- self._db.conn.rollback()
- raise e
+ return self.get_model(key)
+ def replace_model(self, key: str, new_config: AnyModelConfig) -> AnyModelConfig:
+ if key != new_config.key:
+ raise ValueError("key does not match new_config.key")
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ UPDATE models
+ SET
+ config=?
+ WHERE id=?;
+ """,
+ (new_config.model_dump_json(), key),
+ )
+ if cursor.rowcount == 0:
+ raise UnknownModelException("model not found")
return self.get_model(key)
def get_model(self, key: str) -> AnyModelConfig:
@@ -189,33 +247,33 @@ def get_model(self, key: str) -> AnyModelConfig:
Exceptions: UnknownModelException
"""
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT config, strftime('%s',updated_at) FROM models
+ SELECT config FROM models
WHERE id=?;
""",
(key,),
)
- rows = self._cursor.fetchone()
- if not rows:
- raise UnknownModelException("model not found")
- model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
+ rows = cursor.fetchone()
+ if not rows:
+ raise UnknownModelException("model not found")
+ model = ModelConfigFactory.from_dict(json.loads(rows[0]))
return model
def get_model_by_hash(self, hash: str) -> AnyModelConfig:
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT config, strftime('%s',updated_at) FROM models
+ SELECT config FROM models
WHERE hash=?;
""",
(hash,),
)
- rows = self._cursor.fetchone()
- if not rows:
- raise UnknownModelException("model not found")
- model = ModelConfigFactory.make_config(json.loads(rows[0]), timestamp=rows[1])
+ rows = cursor.fetchone()
+ if not rows:
+ raise UnknownModelException("model not found")
+ model = ModelConfigFactory.from_dict(json.loads(rows[0]))
return model
def exists(self, key: str) -> bool:
@@ -224,16 +282,15 @@ def exists(self, key: str) -> bool:
:param key: Unique key for the model to be deleted
"""
- count = 0
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
select count(*) FROM models
WHERE id=?;
""",
(key,),
)
- count = self._cursor.fetchone()[0]
+ count = cursor.fetchone()[0]
return count > 0
def search_by_attr(
@@ -243,6 +300,7 @@ def search_by_attr(
model_type: Optional[ModelType] = None,
model_format: Optional[ModelFormat] = None,
order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default,
+ direction: SQLiteDirection = SQLiteDirection.Ascending,
) -> List[AnyModelConfig]:
"""
Return models matching name, base and/or type.
@@ -252,111 +310,141 @@ def search_by_attr(
:param model_type: Filter by type of model (optional)
:param model_format: Filter by model format (e.g. "diffusers") (optional)
:param order_by: Result order
+ :param direction: Result direction
If none of the optional filters are passed, will return all
models in the database.
"""
-
- assert isinstance(order_by, ModelRecordOrderBy)
- ordering = {
- ModelRecordOrderBy.Default: "type, base, name, format",
- ModelRecordOrderBy.Type: "type",
- ModelRecordOrderBy.Base: "base",
- ModelRecordOrderBy.Name: "name",
- ModelRecordOrderBy.Format: "format",
- }
-
- where_clause: list[str] = []
- bindings: list[str] = []
- if model_name:
- where_clause.append("name=?")
- bindings.append(model_name)
- if base_model:
- where_clause.append("base=?")
- bindings.append(base_model)
- if model_type:
- where_clause.append("type=?")
- bindings.append(model_type)
- if model_format:
- where_clause.append("format=?")
- bindings.append(model_format)
- where = f"WHERE {' AND '.join(where_clause)}" if where_clause else ""
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ assert isinstance(order_by, ModelRecordOrderBy)
+ order_dir = "DESC" if direction == SQLiteDirection.Descending else "ASC"
+ ordering = {
+ ModelRecordOrderBy.Default: f"type {order_dir}, base COLLATE NOCASE {order_dir}, name COLLATE NOCASE {order_dir}, format",
+ ModelRecordOrderBy.Type: "type",
+ ModelRecordOrderBy.Base: "base COLLATE NOCASE",
+ ModelRecordOrderBy.Name: "name COLLATE NOCASE",
+ ModelRecordOrderBy.Format: "format",
+ ModelRecordOrderBy.Size: "IFNULL(json_extract(config, '$.file_size'), 0)",
+ ModelRecordOrderBy.DateAdded: "created_at",
+ ModelRecordOrderBy.DateModified: "updated_at",
+ ModelRecordOrderBy.Path: "path",
+ }
+
+ where_clause: list[str] = []
+ bindings: list[str] = []
+ if model_name:
+ where_clause.append("name=?")
+ bindings.append(model_name)
+ if base_model:
+ where_clause.append("base=?")
+ bindings.append(base_model)
+ if model_type:
+ where_clause.append("type=?")
+ bindings.append(model_type)
+ if model_format:
+ where_clause.append("format=?")
+ bindings.append(model_format)
+ where = f"WHERE {' AND '.join(where_clause)}" if where_clause else ""
+
+ cursor.execute(
f"""--sql
- SELECT config, strftime('%s',updated_at)
+ SELECT config
FROM models
{where}
- ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason;
+ ORDER BY {ordering[order_by]} {order_dir} -- using ? to bind doesn't work here for some reason;
""",
tuple(bindings),
)
- result = self._cursor.fetchall()
- results = [ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in result]
+ result = cursor.fetchall()
+
+ # Parse the model configs.
+ results: list[AnyModelConfig] = []
+ for row in result:
+ try:
+ model_config = ModelConfigFactory.from_dict(json.loads(row[0]))
+ except pydantic.ValidationError as e:
+ # We catch this error so that the app can still run if there are invalid model configs in the database.
+ # One reason that an invalid model config might be in the database is if someone had to rollback from a
+ # newer version of the app that added a new model type.
+ row_data = f"{row[0][:64]}..." if len(row[0]) > 64 else row[0]
+ try:
+ name = json.loads(row[0]).get("name", "")
+ except Exception:
+ name = ""
+ self._logger.warning(
+ f"Skipping invalid model config in the database with name {name}. Ignoring this model. ({row_data})"
+ )
+ self._logger.warning(f"Validation error: {e}")
+ else:
+ results.append(model_config)
+
return results
def search_by_path(self, path: Union[str, Path]) -> List[AnyModelConfig]:
"""Return models with the indicated path."""
- results = []
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT config, strftime('%s',updated_at) FROM models
+ SELECT config FROM models
WHERE path=?;
""",
(str(path),),
)
- results = [
- ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._cursor.fetchall()
- ]
+ results = [ModelConfigFactory.from_dict(json.loads(x[0])) for x in cursor.fetchall()]
return results
def search_by_hash(self, hash: str) -> List[AnyModelConfig]:
"""Return models with the indicated hash."""
- results = []
- with self._db.lock:
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT config, strftime('%s',updated_at) FROM models
+ SELECT config FROM models
WHERE hash=?;
""",
(hash,),
)
- results = [
- ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._cursor.fetchall()
- ]
+ results = [ModelConfigFactory.from_dict(json.loads(x[0])) for x in cursor.fetchall()]
return results
def list_models(
- self, page: int = 0, per_page: int = 10, order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default
+ self,
+ page: int = 0,
+ per_page: int = 10,
+ order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default,
+ direction: SQLiteDirection = SQLiteDirection.Ascending,
) -> PaginatedResults[ModelSummary]:
"""Return a paginated summary listing of each model in the database."""
- assert isinstance(order_by, ModelRecordOrderBy)
- ordering = {
- ModelRecordOrderBy.Default: "type, base, name, format",
- ModelRecordOrderBy.Type: "type",
- ModelRecordOrderBy.Base: "base",
- ModelRecordOrderBy.Name: "name",
- ModelRecordOrderBy.Format: "format",
- }
-
- # Lock so that the database isn't updated while we're doing the two queries.
- with self._db.lock:
+ with self._db.transaction() as cursor:
+ assert isinstance(order_by, ModelRecordOrderBy)
+ order_dir = "DESC" if direction == SQLiteDirection.Descending else "ASC"
+ ordering = {
+ ModelRecordOrderBy.Default: f"type {order_dir}, base COLLATE NOCASE {order_dir}, name COLLATE NOCASE {order_dir}, format",
+ ModelRecordOrderBy.Type: "type",
+ ModelRecordOrderBy.Base: "base COLLATE NOCASE",
+ ModelRecordOrderBy.Name: "name COLLATE NOCASE",
+ ModelRecordOrderBy.Format: "format",
+ ModelRecordOrderBy.Size: "IFNULL(json_extract(config, '$.file_size'), 0)",
+ ModelRecordOrderBy.DateAdded: "created_at",
+ ModelRecordOrderBy.DateModified: "updated_at",
+ ModelRecordOrderBy.Path: "path",
+ }
+
+ # Lock so that the database isn't updated while we're doing the two queries.
# query1: get the total number of model configs
- self._cursor.execute(
+ cursor.execute(
"""--sql
select count(*) from models;
""",
(),
)
- total = int(self._cursor.fetchone()[0])
+ total = int(cursor.fetchone()[0])
# query2: fetch key fields
- self._cursor.execute(
+ cursor.execute(
f"""--sql
SELECT config
FROM models
- ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason
+ ORDER BY {ordering[order_by]} {order_dir} -- using ? to bind doesn't work here for some reason
LIMIT ?
OFFSET ?;
""",
@@ -365,8 +453,6 @@ def list_models(
page * per_page,
),
)
- rows = self._cursor.fetchall()
- items = [ModelSummary.model_validate(dict(x)) for x in rows]
- return PaginatedResults(
- page=page, pages=ceil(total / per_page), per_page=per_page, total=total, items=items
- )
+ rows = cursor.fetchall()
+ items = [ModelSummary.model_validate(dict(x)) for x in rows]
+ return PaginatedResults(page=page, pages=ceil(total / per_page), per_page=per_page, total=total, items=items)
diff --git a/invokeai/app/services/model_relationship_records/model_relationship_records_base.py b/invokeai/app/services/model_relationship_records/model_relationship_records_base.py
new file mode 100644
index 00000000000..94fa179bc0a
--- /dev/null
+++ b/invokeai/app/services/model_relationship_records/model_relationship_records_base.py
@@ -0,0 +1,25 @@
+from abc import ABC, abstractmethod
+
+
+class ModelRelationshipRecordStorageBase(ABC):
+ """Abstract base class for model-to-model relationship record storage."""
+
+ @abstractmethod
+ def add_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ """Creates a relationship between two models by keys."""
+ pass
+
+ @abstractmethod
+ def remove_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ """Removes a relationship between two models by keys."""
+ pass
+
+ @abstractmethod
+ def get_related_model_keys(self, model_key: str) -> list[str]:
+ """Gets all models keys related to a given model key."""
+ pass
+
+ @abstractmethod
+ def get_related_model_keys_batch(self, model_keys: list[str]) -> list[str]:
+ """Get related model keys for multiple models given a list of keys."""
+ pass
diff --git a/invokeai/app/services/model_relationship_records/model_relationship_records_sqlite.py b/invokeai/app/services/model_relationship_records/model_relationship_records_sqlite.py
new file mode 100644
index 00000000000..c12990b8c3a
--- /dev/null
+++ b/invokeai/app/services/model_relationship_records/model_relationship_records_sqlite.py
@@ -0,0 +1,55 @@
+from invokeai.app.services.model_relationship_records.model_relationship_records_base import (
+ ModelRelationshipRecordStorageBase,
+)
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class SqliteModelRelationshipRecordStorage(ModelRelationshipRecordStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def add_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ with self._db.transaction() as cursor:
+ if model_key_1 == model_key_2:
+ raise ValueError("Cannot relate a model to itself.")
+ a, b = sorted([model_key_1, model_key_2])
+ cursor.execute(
+ "INSERT OR IGNORE INTO model_relationships (model_key_1, model_key_2) VALUES (?, ?)",
+ (a, b),
+ )
+
+ def remove_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ with self._db.transaction() as cursor:
+ a, b = sorted([model_key_1, model_key_2])
+ cursor.execute(
+ "DELETE FROM model_relationships WHERE model_key_1 = ? AND model_key_2 = ?",
+ (a, b),
+ )
+
+ def get_related_model_keys(self, model_key: str) -> list[str]:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT model_key_2 FROM model_relationships WHERE model_key_1 = ?
+ UNION
+ SELECT model_key_1 FROM model_relationships WHERE model_key_2 = ?
+ """,
+ (model_key, model_key),
+ )
+ result = [row[0] for row in cursor.fetchall()]
+ return result
+
+ def get_related_model_keys_batch(self, model_keys: list[str]) -> list[str]:
+ with self._db.transaction() as cursor:
+ key_list = ",".join("?" for _ in model_keys)
+ cursor.execute(
+ f"""
+ SELECT model_key_2 FROM model_relationships WHERE model_key_1 IN ({key_list})
+ UNION
+ SELECT model_key_1 FROM model_relationships WHERE model_key_2 IN ({key_list})
+ """,
+ model_keys + model_keys,
+ )
+ result = [row[0] for row in cursor.fetchall()]
+ return result
diff --git a/invokeai/app/services/model_relationships/model_relationships_base.py b/invokeai/app/services/model_relationships/model_relationships_base.py
new file mode 100644
index 00000000000..1ea744a8dcb
--- /dev/null
+++ b/invokeai/app/services/model_relationships/model_relationships_base.py
@@ -0,0 +1,25 @@
+from abc import ABC, abstractmethod
+
+
+class ModelRelationshipsServiceABC(ABC):
+ """High-level service for managing model-to-model relationships."""
+
+ @abstractmethod
+ def add_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ """Creates a relationship between two models keys."""
+ pass
+
+ @abstractmethod
+ def remove_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ """Removes a relationship between two models keys."""
+ pass
+
+ @abstractmethod
+ def get_related_model_keys(self, model_key: str) -> list[str]:
+ """Gets all models keys related to a given model key."""
+ pass
+
+ @abstractmethod
+ def get_related_model_keys_batch(self, model_keys: list[str]) -> list[str]:
+ """Get related model keys for multiple models."""
+ pass
diff --git a/invokeai/app/services/model_relationships/model_relationships_common.py b/invokeai/app/services/model_relationships/model_relationships_common.py
new file mode 100644
index 00000000000..5876be6b0b6
--- /dev/null
+++ b/invokeai/app/services/model_relationships/model_relationships_common.py
@@ -0,0 +1,9 @@
+from datetime import datetime
+
+from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
+
+
+class ModelRelationship(BaseModelExcludeNull):
+ model_key_1: str
+ model_key_2: str
+ created_at: datetime
diff --git a/invokeai/app/services/model_relationships/model_relationships_default.py b/invokeai/app/services/model_relationships/model_relationships_default.py
new file mode 100644
index 00000000000..e4da482ff27
--- /dev/null
+++ b/invokeai/app/services/model_relationships/model_relationships_default.py
@@ -0,0 +1,31 @@
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.model_relationships.model_relationships_base import ModelRelationshipsServiceABC
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
+
+
+class ModelRelationshipsService(ModelRelationshipsServiceABC):
+ __invoker: Invoker
+
+ def start(self, invoker: Invoker) -> None:
+ self.__invoker = invoker
+
+ def add_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ self.__invoker.services.model_relationship_records.add_model_relationship(model_key_1, model_key_2)
+
+ def remove_model_relationship(self, model_key_1: str, model_key_2: str) -> None:
+ self.__invoker.services.model_relationship_records.remove_model_relationship(model_key_1, model_key_2)
+
+ def get_related_model_keys(self, model_key: str) -> list[str]:
+ return self.__invoker.services.model_relationship_records.get_related_model_keys(model_key)
+
+ def add_relationship_from_models(self, model_1: AnyModelConfig, model_2: AnyModelConfig) -> None:
+ self.add_model_relationship(model_1.key, model_2.key)
+
+ def remove_relationship_from_models(self, model_1: AnyModelConfig, model_2: AnyModelConfig) -> None:
+ self.remove_model_relationship(model_1.key, model_2.key)
+
+ def get_related_keys_from_model(self, model: AnyModelConfig) -> list[str]:
+ return self.get_related_model_keys(model.key)
+
+ def get_related_model_keys_batch(self, model_keys: list[str]) -> list[str]:
+ return self.__invoker.services.model_relationship_records.get_related_model_keys_batch(model_keys)
diff --git a/invokeai/app/services/names/names_default.py b/invokeai/app/services/names/names_default.py
index 104268c8bdd..5804a937d6a 100644
--- a/invokeai/app/services/names/names_default.py
+++ b/invokeai/app/services/names/names_default.py
@@ -1,7 +1,6 @@
+from invokeai.app.services.names.names_base import NameServiceBase
from invokeai.app.util.misc import uuid_string
-from .names_base import NameServiceBase
-
class SimpleNameService(NameServiceBase):
"""Creates image names from UUIDs."""
diff --git a/invokeai/app/services/object_serializer/object_serializer_disk.py b/invokeai/app/services/object_serializer/object_serializer_disk.py
index 8edd29e1505..bbd3f785507 100644
--- a/invokeai/app/services/object_serializer/object_serializer_disk.py
+++ b/invokeai/app/services/object_serializer/object_serializer_disk.py
@@ -21,10 +21,16 @@ class ObjectSerializerDisk(ObjectSerializerBase[T]):
"""Disk-backed storage for arbitrary python objects. Serialization is handled by `torch.save` and `torch.load`.
:param output_dir: The folder where the serialized objects will be stored
+ :param safe_globals: A list of types to be added to the safe globals for torch serialization
:param ephemeral: If True, objects will be stored in a temporary directory inside the given output_dir and cleaned up on exit
"""
- def __init__(self, output_dir: Path, ephemeral: bool = False):
+ def __init__(
+ self,
+ output_dir: Path,
+ safe_globals: list[type],
+ ephemeral: bool = False,
+ ) -> None:
super().__init__()
self._ephemeral = ephemeral
self._base_output_dir = output_dir
@@ -42,6 +48,8 @@ def __init__(self, output_dir: Path, ephemeral: bool = False):
self._output_dir = Path(self._tempdir.name) if self._tempdir else self._base_output_dir
self.__obj_class_name: Optional[str] = None
+ torch.serialization.add_safe_globals(safe_globals) if safe_globals else None
+
def load(self, name: str) -> T:
file_path = self._get_path(name)
try:
diff --git a/invokeai/app/services/object_serializer/object_serializer_forward_cache.py b/invokeai/app/services/object_serializer/object_serializer_forward_cache.py
index b361259a4b1..ae00173e422 100644
--- a/invokeai/app/services/object_serializer/object_serializer_forward_cache.py
+++ b/invokeai/app/services/object_serializer/object_serializer_forward_cache.py
@@ -1,4 +1,5 @@
from queue import Queue
+from threading import Lock
from typing import TYPE_CHECKING, Optional, TypeVar
from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase
@@ -21,6 +22,9 @@ def __init__(self, underlying_storage: ObjectSerializerBase[T], max_cache_size:
self._cache: dict[str, T] = {}
self._cache_ids = Queue[str]()
self._max_cache_size = max_cache_size
+ # Guards the in-memory cache so concurrent session-processor workers (multi-GPU) can't race
+ # the check-then-evict in `_set_cache` (which could otherwise raise KeyError on eviction).
+ self._cache_lock = Lock()
def start(self, invoker: "Invoker") -> None:
self._invoker = invoker
@@ -50,16 +54,19 @@ def save(self, obj: T) -> str:
def delete(self, name: str) -> None:
self._underlying_storage.delete(name)
- if name in self._cache:
- del self._cache[name]
+ with self._cache_lock:
+ if name in self._cache:
+ del self._cache[name]
self._on_deleted(name)
def _get_cache(self, name: str) -> Optional[T]:
- return None if name not in self._cache else self._cache[name]
+ with self._cache_lock:
+ return None if name not in self._cache else self._cache[name]
def _set_cache(self, name: str, data: T):
- if name not in self._cache:
- self._cache[name] = data
- self._cache_ids.put(name)
- if self._cache_ids.qsize() > self._max_cache_size:
- self._cache.pop(self._cache_ids.get())
+ with self._cache_lock:
+ if name not in self._cache:
+ self._cache[name] = data
+ self._cache_ids.put(name)
+ if self._cache_ids.qsize() > self._max_cache_size:
+ self._cache.pop(self._cache_ids.get())
diff --git a/invokeai/app/services/orphaned_models/__init__.py b/invokeai/app/services/orphaned_models/__init__.py
new file mode 100644
index 00000000000..db9eaae7bb4
--- /dev/null
+++ b/invokeai/app/services/orphaned_models/__init__.py
@@ -0,0 +1,5 @@
+"""Service for finding and removing orphaned model files."""
+
+from invokeai.app.services.orphaned_models.orphaned_models_service import OrphanedModelInfo, OrphanedModelsService
+
+__all__ = ["OrphanedModelsService", "OrphanedModelInfo"]
diff --git a/invokeai/app/services/orphaned_models/orphaned_models_service.py b/invokeai/app/services/orphaned_models/orphaned_models_service.py
new file mode 100644
index 00000000000..8d2894c8671
--- /dev/null
+++ b/invokeai/app/services/orphaned_models/orphaned_models_service.py
@@ -0,0 +1,209 @@
+"""Service for finding and removing orphaned model files.
+
+Orphaned models are files in the models directory that are not referenced
+in the database models table.
+"""
+
+import json
+import shutil
+from pathlib import Path
+from typing import Set
+
+from pydantic import BaseModel, Field
+
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+
+
+class OrphanedModelInfo(BaseModel):
+ """Information about an orphaned model directory."""
+
+ path: str = Field(description="Relative path to the orphaned directory from models root")
+ absolute_path: str = Field(description="Absolute path to the orphaned directory")
+ files: list[str] = Field(description="List of model files in this directory")
+ size_bytes: int = Field(description="Total size of all files in bytes")
+
+
+class OrphanedModelsService:
+ """Service for finding and removing orphaned model files."""
+
+ # Common model file extensions
+ MODEL_EXTENSIONS = {
+ ".safetensors",
+ ".ckpt",
+ ".pt",
+ ".pth",
+ ".bin",
+ ".onnx",
+ ".gguf",
+ }
+
+ # Directories to skip during scan
+ SKIP_DIRS = {
+ ".download_cache",
+ ".convert_cache",
+ "__pycache__",
+ ".git",
+ }
+
+ def __init__(self, config: InvokeAIAppConfig, db: SqliteDatabase):
+ """Initialize the service.
+
+ Args:
+ config: Application configuration containing models path
+ db: Database connection for querying registered models
+ """
+ self._config = config
+ self._db = db
+
+ def find_orphaned_models(self) -> list[OrphanedModelInfo]:
+ """Find all orphaned model directories.
+
+ Returns:
+ List of OrphanedModelInfo objects describing orphaned directories
+ """
+ models_path = self._config.models_path
+
+ # Get all model directories registered in the database
+ db_model_directories = self._get_registered_model_directories(models_path)
+
+ # Find all model files on disk
+ disk_model_files = self._get_all_model_files(models_path)
+
+ # Find orphaned files (files not under any registered model directory)
+ orphaned_files = set()
+ for disk_file in disk_model_files:
+ is_under_model_dir = False
+ for model_dir in db_model_directories:
+ try:
+ # Check if disk_file is under model_dir
+ disk_file.relative_to(model_dir)
+ is_under_model_dir = True
+ break
+ except ValueError:
+ # Not under this model directory, continue checking
+ continue
+
+ if not is_under_model_dir:
+ orphaned_files.add(disk_file)
+
+ # Group orphaned files by their top-level directory
+ orphaned_dirs_map: dict[Path, list[Path]] = {}
+ for orphaned_file in orphaned_files:
+ # Get the top-level directory relative to models_path
+ try:
+ rel_path = orphaned_file.relative_to(models_path)
+ if rel_path.parts:
+ top_level_dir = models_path / rel_path.parts[0]
+ if top_level_dir not in orphaned_dirs_map:
+ orphaned_dirs_map[top_level_dir] = []
+ orphaned_dirs_map[top_level_dir].append(orphaned_file)
+ except ValueError:
+ # File is outside models_path, skip it
+ continue
+
+ # Convert to OrphanedModelInfo objects
+ result = []
+ for dir_path, files in orphaned_dirs_map.items():
+ # Calculate total size
+ total_size = sum(f.stat().st_size for f in files if f.exists())
+
+ # Get relative file paths
+ file_names = [str(f.relative_to(dir_path)) for f in files]
+
+ result.append(
+ OrphanedModelInfo(
+ path=str(dir_path.relative_to(models_path)),
+ absolute_path=str(dir_path),
+ files=file_names,
+ size_bytes=total_size,
+ )
+ )
+
+ return result
+
+ def delete_orphaned_models(self, orphaned_paths: list[str]) -> dict[str, str]:
+ """Delete the specified orphaned model directories.
+
+ Args:
+ orphaned_paths: List of relative paths to delete (relative to models root)
+
+ Returns:
+ Dictionary mapping paths to status messages ("deleted" or error message)
+ """
+ models_path = self._config.models_path
+ results = {}
+
+ for rel_path in orphaned_paths:
+ try:
+ full_path = models_path / rel_path
+ if not full_path.exists():
+ results[rel_path] = "error: path does not exist"
+ continue
+
+ # Safety check: ensure path is under models directory
+ try:
+ full_path.relative_to(models_path)
+ except ValueError:
+ results[rel_path] = "error: path is not under models directory"
+ continue
+
+ # Delete the directory
+ shutil.rmtree(full_path)
+ results[rel_path] = "deleted"
+
+ except Exception as e:
+ results[rel_path] = f"error: {str(e)}"
+
+ return results
+
+ def _get_registered_model_directories(self, models_dir: Path) -> Set[Path]:
+ """Get the set of all model directories from the database."""
+ model_directories = set()
+
+ with self._db.transaction() as cursor:
+ cursor.execute("SELECT config FROM models")
+ rows = cursor.fetchall()
+
+ for row in rows:
+ try:
+ config = json.loads(row[0])
+ if "path" in config and config["path"]:
+ path_str = config["path"]
+ path = Path(path_str)
+
+ # If the path is relative, resolve it relative to models_dir
+ if not path.is_absolute():
+ full_path = (models_dir / path).resolve()
+ else:
+ full_path = path.resolve()
+
+ # Extract the top-level directory under models_dir
+ try:
+ rel_path = full_path.relative_to(models_dir)
+ if rel_path.parts:
+ top_level_dir = models_dir / rel_path.parts[0]
+ model_directories.add(top_level_dir.resolve())
+ except ValueError:
+ # Path is not relative to models_dir
+ model_directories.add(full_path)
+
+ except (json.JSONDecodeError, KeyError, TypeError):
+ # Skip invalid model configs
+ continue
+
+ return model_directories
+
+ def _get_all_model_files(self, models_path: Path) -> Set[Path]:
+ """Get all model files in the models directory."""
+ model_files = set()
+
+ for item in models_path.rglob("*"):
+ # Skip directories we don't want to scan
+ if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
+ continue
+
+ if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
+ model_files.add(item.resolve())
+
+ return model_files
diff --git a/invokeai/app/services/session_processor/session_processor_common.py b/invokeai/app/services/session_processor/session_processor_common.py
index 0ca51de517c..346f12d8bbc 100644
--- a/invokeai/app/services/session_processor/session_processor_common.py
+++ b/invokeai/app/services/session_processor/session_processor_common.py
@@ -1,5 +1,8 @@
+from PIL.Image import Image as PILImageType
from pydantic import BaseModel, Field
+from invokeai.backend.util.util import image_to_dataURL
+
class SessionProcessorStatus(BaseModel):
is_started: bool = Field(description="Whether the session processor is started")
@@ -15,6 +18,16 @@ class CanceledException(Exception):
class ProgressImage(BaseModel):
"""The progress image sent intermittently during processing"""
- width: int = Field(description="The effective width of the image in pixels")
- height: int = Field(description="The effective height of the image in pixels")
+ width: int = Field(ge=1, description="The effective width of the image in pixels")
+ height: int = Field(ge=1, description="The effective height of the image in pixels")
dataURL: str = Field(description="The image data as a b64 data URL")
+
+ @classmethod
+ def build(cls, image: PILImageType, size: tuple[int, int] | None = None) -> "ProgressImage":
+ """Build a ProgressImage from a PIL image"""
+
+ return cls(
+ width=size[0] if size else image.width,
+ height=size[1] if size else image.height,
+ dataURL=image_to_dataURL(image, image_format="JPEG"),
+ )
diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py
index 3f348fb239d..e776dc79614 100644
--- a/invokeai/app/services/session_processor/session_processor_default.py
+++ b/invokeai/app/services/session_processor/session_processor_default.py
@@ -1,8 +1,11 @@
+import gc
import traceback
-from contextlib import suppress
+from contextlib import contextmanager, suppress
from threading import BoundedSemaphore, Thread
from threading import Event as ThreadEvent
-from typing import Optional
+from typing import Iterator, Optional
+
+import torch
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput
from invokeai.app.services.events.events_common import (
@@ -13,23 +16,25 @@
register_events,
)
from invokeai.app.services.invocation_stats.invocation_stats_common import GESStatsNotFoundError
+from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_processor.session_processor_base import (
+ InvocationServices,
OnAfterRunNode,
OnAfterRunSession,
OnBeforeRunNode,
OnBeforeRunSession,
OnNodeError,
OnNonFatalProcessorError,
+ SessionProcessorBase,
+ SessionRunnerBase,
)
-from invokeai.app.services.session_processor.session_processor_common import CanceledException
+from invokeai.app.services.session_processor.session_processor_common import CanceledException, SessionProcessorStatus
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem, SessionQueueItemNotFoundError
from invokeai.app.services.shared.graph import NodeInputError
from invokeai.app.services.shared.invocation_context import InvocationContextData, build_invocation_context
from invokeai.app.util.profiler import Profiler
-
-from ..invoker import Invoker
-from .session_processor_base import InvocationServices, SessionProcessorBase, SessionRunnerBase
-from .session_processor_common import SessionProcessorStatus
+from invokeai.backend.util.device_pool import GENERATION_DEVICE_POOL
+from invokeai.backend.util.devices import TorchDevice
class DefaultSessionRunner(SessionRunnerBase):
@@ -125,16 +130,14 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
is_canceled=self._is_canceled,
)
- # Invoke the node
- output = invocation.invoke_internal(context=context, services=self._services)
+ # Invoke the node, optionally on a borrowed idle GPU (text encoders only).
+ with self._maybe_offload_to_idle_gpu(invocation):
+ output = invocation.invoke_internal(context=context, services=self._services)
# Save output and history
queue_item.session.complete(invocation.id, output)
self._on_after_run_node(invocation, queue_item, output)
- except KeyboardInterrupt:
- # TODO(psyche): This is expected to be caught in the main thread. Do we need to catch this here?
- pass
except CanceledException:
# A CanceledException is raised during the denoising step callback if the cancel event is set. We don't need
# to do any handling here, and no error should be set - just pass and the cancellation will be handled
@@ -155,6 +158,45 @@ def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem):
error_traceback=error_traceback,
)
+ @contextmanager
+ def _maybe_offload_to_idle_gpu(self, invocation: BaseInvocation) -> Iterator[None]:
+ """Temporarily re-pin this worker thread to an idle GPU for a text-encoder node.
+
+ When ``offload_text_encoders_to_idle_gpus`` is enabled and an idle generation GPU can be
+ borrowed, the encoder model loads into that GPU's cache and its forward runs there (all
+ device-selecting code resolves to the pinned device), keeping the busy GPU's denoise model
+ resident. The conditioning output is stored on the CPU, so the denoiser picks it up on the
+ worker's own GPU after the pin is restored.
+
+ The borrow holds the idle device's exclusive-use lock for the whole node, so a native
+ session on that GPU can never run concurrently against the same cached encoder (which would
+ corrupt it). If no idle GPU is free, the node runs on the worker's own GPU unchanged.
+ """
+ native_device = TorchDevice.get_session_device()
+ if (
+ native_device is None
+ or native_device.type != "cuda"
+ or not invocation.idle_gpu_offloadable
+ or not self._services.configuration.offload_text_encoders_to_idle_gpus
+ ):
+ yield
+ return
+
+ borrowed_device = GENERATION_DEVICE_POOL.try_borrow(exclude=native_device)
+ if borrowed_device is None:
+ yield
+ return
+
+ self._services.logger.debug(
+ f"Running {invocation.get_type()} on idle device {borrowed_device} (session device {native_device})."
+ )
+ TorchDevice.set_session_device(borrowed_device)
+ try:
+ yield
+ finally:
+ TorchDevice.set_session_device(native_device)
+ GENERATION_DEVICE_POOL.release_borrow(borrowed_device)
+
def _on_before_run_session(self, queue_item: SessionQueueItem) -> None:
"""Called before a session is run.
@@ -210,7 +252,7 @@ def _on_after_run_session(self, queue_item: SessionQueueItem) -> None:
# we don't care about that - suppress the error.
with suppress(GESStatsNotFoundError):
self._services.performance_statistics.log_stats(queue_item.session.id)
- self._services.performance_statistics.reset_stats()
+ self._services.performance_statistics.reset_stats(queue_item.session.id)
for callback in self._on_after_run_session_callbacks:
callback(queue_item=queue_item)
@@ -307,6 +349,26 @@ def _on_node_error(
)
+class _SessionWorker:
+ """A single generation worker: one thread, optionally pinned to one device.
+
+ In single-device (legacy) mode there is exactly one worker with `device=None`. In multi-GPU
+ mode there is one worker per configured device, each with its own session runner and cancel
+ event so concurrent sessions can be canceled independently.
+ """
+
+ def __init__(self, device: Optional[torch.device], runner: SessionRunnerBase) -> None:
+ self.device = device
+ self.runner = runner
+ self.cancel_event = ThreadEvent()
+ self.queue_item: Optional[SessionQueueItem] = None
+ self.thread: Optional[Thread] = None
+
+ @property
+ def label(self) -> str:
+ return str(self.device) if self.device is not None else "default device"
+
+
class DefaultSessionProcessor(SessionProcessorBase):
def __init__(
self,
@@ -321,74 +383,149 @@ def __init__(
self._on_non_fatal_processor_error_callbacks = on_non_fatal_processor_error_callbacks or []
self._thread_limit = thread_limit
self._polling_interval = polling_interval
+ self._workers: list[_SessionWorker] = []
+
+ def _resolve_devices(self) -> list[Optional[torch.device]]:
+ """Determine the per-worker devices from config.
+
+ Resolves `generation_devices` (which defaults to `"auto"` — every available GPU) into one
+ normalized device per worker. Returns a single `None` (legacy single-worker, device chosen by
+ the global config) only if the resolution is empty (e.g. `generation_devices` set to an empty
+ list).
+ """
+ generation_devices = self._invoker.services.configuration.generation_devices
+ devices = TorchDevice.get_generation_devices(generation_devices)
+ if not devices:
+ return [None]
+ return list(devices)
+
+ def _clone_session_runner(self, template: SessionRunnerBase) -> SessionRunnerBase:
+ """Create an independent runner for an additional worker.
+
+ Each worker needs its own runner because the runner stores its session's cancel event.
+ We carry over the template's callbacks so all workers behave identically.
+ """
+ if isinstance(template, DefaultSessionRunner):
+ return DefaultSessionRunner(
+ on_before_run_session_callbacks=list(template._on_before_run_session_callbacks),
+ on_before_run_node_callbacks=list(template._on_before_run_node_callbacks),
+ on_after_run_node_callbacks=list(template._on_after_run_node_callbacks),
+ on_node_error_callbacks=list(template._on_node_error_callbacks),
+ on_after_run_session_callbacks=list(template._on_after_run_session_callbacks),
+ )
+ # Unknown runner implementation — only safe to reuse in single-worker mode.
+ return template
def start(self, invoker: Invoker) -> None:
self._invoker: Invoker = invoker
- self._queue_item: Optional[SessionQueueItem] = None
- self._invocation: Optional[BaseInvocation] = None
self._resume_event = ThreadEvent()
self._stop_event = ThreadEvent()
self._poll_now_event = ThreadEvent()
- self._cancel_event = ThreadEvent()
register_events(QueueClearedEvent, self._on_queue_cleared)
register_events(BatchEnqueuedEvent, self._on_batch_enqueued)
register_events(QueueItemStatusChangedEvent, self._on_queue_item_status_changed)
- self._thread_semaphore = BoundedSemaphore(self._thread_limit)
+ devices = self._resolve_devices()
+
+ # Register the generation devices so the model loader can discover idle GPUs to host text
+ # encoders on (see offload_text_encoders_to_idle_gpus). None means legacy single-device mode.
+ GENERATION_DEVICE_POOL.set_generation_devices([d for d in devices if d is not None])
# If profiling is enabled, create a profiler. The same profiler will be used for all sessions. Internally,
- # the profiler will create a new profile for each session.
+ # the profiler will create a new profile for each session. Profiling uses a process-global cProfile, which
+ # cannot cleanly attribute work when multiple sessions run concurrently, so it is disabled in multi-GPU mode.
+ profiler_enabled = self._invoker.services.configuration.profile_graphs
+ if profiler_enabled and len(devices) > 1:
+ self._invoker.services.logger.warning(
+ "Graph profiling is disabled because multiple generation devices are configured."
+ )
+ profiler_enabled = False
self._profiler = (
Profiler(
logger=self._invoker.services.logger,
output_dir=self._invoker.services.configuration.profiles_path,
prefix=self._invoker.services.configuration.profile_prefix,
)
- if self._invoker.services.configuration.profile_graphs
+ if profiler_enabled
else None
)
- self.session_runner.start(services=invoker.services, cancel_event=self._cancel_event, profiler=self._profiler)
- self._thread = Thread(
- name="session_processor",
- target=self._process,
- kwargs={
- "stop_event": self._stop_event,
- "poll_now_event": self._poll_now_event,
- "resume_event": self._resume_event,
- "cancel_event": self._cancel_event,
- },
- )
- self._thread.start()
+ self._thread_semaphore = BoundedSemaphore(len(devices))
+
+ # Start in the running (resumed) state.
+ self._stop_event.clear()
+ self._resume_event.set()
+
+ self._workers = []
+ for index, device in enumerate(devices):
+ runner = self.session_runner if index == 0 else self._clone_session_runner(self.session_runner)
+ worker = _SessionWorker(device=device, runner=runner)
+ runner.start(services=invoker.services, cancel_event=worker.cancel_event, profiler=self._profiler)
+ self._workers.append(worker)
+
+ if len(self._workers) > 1:
+ self._invoker.services.logger.info(
+ f"Starting session processor with {len(self._workers)} parallel workers on devices: "
+ f"{', '.join(w.label for w in self._workers)}"
+ )
+
+ for index, worker in enumerate(self._workers):
+ worker.thread = Thread(
+ name=f"session_processor_{index}",
+ target=self._process,
+ daemon=True,
+ kwargs={
+ "worker": worker,
+ "stop_event": self._stop_event,
+ "poll_now_event": self._poll_now_event,
+ "resume_event": self._resume_event,
+ },
+ )
+ worker.thread.start()
def stop(self, *args, **kwargs) -> None:
self._stop_event.set()
+ # Cancel any in-progress generation so that long-running nodes (e.g. denoising) stop at
+ # the next step boundary instead of running to completion. Without this, a generation
+ # thread may still be executing CUDA operations when Python teardown begins, which can
+ # cause a C++ std::terminate() crash ("terminate called without an active exception").
+ for worker in self._workers:
+ worker.cancel_event.set()
+ # Wake any worker sleeping in poll_now_event.wait() or blocked in resume_event.wait() (paused).
+ self._poll_now_event.set()
+ self._resume_event.set()
def _poll_now(self) -> None:
self._poll_now_event.set()
async def _on_queue_cleared(self, event: FastAPIEvent[QueueClearedEvent]) -> None:
- if self._queue_item and self._queue_item.queue_id == event[1].queue_id:
- self._cancel_event.set()
+ # Cancel every worker currently running an item from the cleared queue.
+ canceled = False
+ for worker in self._workers:
+ if worker.queue_item and worker.queue_item.queue_id == event[1].queue_id:
+ worker.cancel_event.set()
+ canceled = True
+ if canceled:
self._poll_now()
async def _on_batch_enqueued(self, event: FastAPIEvent[BatchEnqueuedEvent]) -> None:
self._poll_now()
async def _on_queue_item_status_changed(self, event: FastAPIEvent[QueueItemStatusChangedEvent]) -> None:
- if self._queue_item and event[1].status in ["completed", "failed", "canceled"]:
- # When the queue item is canceled via HTTP, the queue item status is set to `"canceled"` and this event is
- # emitted. We need to respond to this event and stop graph execution. This is done by setting the cancel
- # event, which the session runner checks between invocations. If set, the session runner loop is broken.
- #
- # Long-running nodes that cannot be interrupted easily present a challenge. `denoise_latents` is one such
- # node, but it gets a step callback, called on each step of denoising. This callback checks if the queue item
- # is canceled, and if it is, raises a `CanceledException` to stop execution immediately.
- if event[1].status == "canceled":
- self._cancel_event.set()
- self._poll_now()
+ # Find the worker (if any) currently running the item whose status changed.
+ for worker in self._workers:
+ if worker.queue_item and worker.queue_item.item_id == event[1].item_id:
+ if event[1].status in ["completed", "failed", "canceled"]:
+ # When the queue item is canceled via HTTP, the status is set to "canceled" and this event is
+ # emitted. We respond by setting that worker's cancel event, which its session runner checks
+ # between invocations (and which denoise_latents' step callback checks mid-node, raising
+ # CanceledException to stop immediately).
+ if event[1].status == "canceled":
+ worker.cancel_event.set()
+ self._poll_now()
+ return
def resume(self) -> SessionProcessorStatus:
if not self._resume_event.is_set():
@@ -403,22 +540,41 @@ def pause(self) -> SessionProcessorStatus:
def get_status(self) -> SessionProcessorStatus:
return SessionProcessorStatus(
is_started=self._resume_event.is_set(),
- is_processing=self._queue_item is not None,
+ is_processing=any(worker.queue_item is not None for worker in self._workers),
)
+ def _is_queue_item_terminal(self, item_id: int) -> bool:
+ """Return True if the queue item is already finished (canceled/failed/completed) or gone.
+
+ Checked right after a worker claims an item to catch a cancellation that raced the claim and
+ so never reached this worker's cancel_event — e.g. the status-changed handler ran before the
+ worker recorded `queue_item` and so couldn't match a worker to signal.
+ """
+ try:
+ status = self._invoker.services.session_queue.get_queue_item(item_id).status
+ except SessionQueueItemNotFoundError:
+ return True
+ return status in ("canceled", "failed", "completed")
+
def _process(
self,
+ worker: _SessionWorker,
stop_event: ThreadEvent,
poll_now_event: ThreadEvent,
resume_event: ThreadEvent,
- cancel_event: ThreadEvent,
):
try:
- # Any unhandled exception in this block is a fatal processor error and will stop the processor.
+ # Any unhandled exception in this block is a fatal processor error and will stop this worker.
self._thread_semaphore.acquire()
- stop_event.clear()
- resume_event.set()
- cancel_event.clear()
+
+ # Pin this worker thread to its device so all device-selecting code (TorchDevice.choose_torch_device,
+ # which nodes and the model loader consult) resolves to this GPU. CUDA's current device is per-thread.
+ if worker.device is not None:
+ TorchDevice.set_session_device(worker.device)
+ if worker.device.type == "cuda":
+ torch.cuda.set_device(worker.device)
+
+ worker.cancel_event.clear()
while not stop_event.is_set():
poll_now_event.clear()
@@ -427,27 +583,67 @@ def _process(
# If we are paused, wait for resume event
resume_event.wait()
- # Get the next session to process
- self._queue_item = self._invoker.services.session_queue.dequeue()
+ if stop_event.is_set():
+ break
+
+ # Clear any stale cancel signal from the previous item BEFORE claiming the next
+ # one. Clearing it after dequeue (as before) could wipe a cancel that arrived for
+ # the item we just claimed — e.g. during the gc.collect() below — silently losing
+ # the cancellation. Any cancel that arrives after this point for the claimed item
+ # stays set and is caught by the runner's _is_canceled() check.
+ worker.cancel_event.clear()
+
+ # Get the next session to process. dequeue() atomically claims the item, so concurrent
+ # workers never receive the same item. Pass this worker's device so the item is
+ # tagged with the GPU that ran it (None in single-device/legacy mode).
+ worker.queue_item = self._invoker.services.session_queue.dequeue(
+ device=str(worker.device) if worker.device is not None else None
+ )
- if self._queue_item is None:
+ if worker.queue_item is None:
# The queue was empty, wait for next polling interval or event to try again
self._invoker.services.logger.debug("Waiting for next polling interval or event")
poll_now_event.wait(self._polling_interval)
continue
- self._invoker.services.logger.debug(f"Executing queue item {self._queue_item.item_id}")
- cancel_event.clear()
+ # A cancellation can race the claim: it may have marked the row terminal before
+ # this worker recorded `queue_item`, so _on_queue_item_status_changed couldn't set
+ # our cancel_event. Re-check (cancel_event + a fresh DB status read) and skip
+ # running if the item is already finished, so the cancel is never lost.
+ if worker.cancel_event.is_set() or self._is_queue_item_terminal(worker.queue_item.item_id):
+ self._invoker.services.logger.debug(
+ f"Queue item {worker.queue_item.item_id} was canceled before it started; skipping."
+ )
+ continue
+
+ # GC-ing here can reduce peak memory usage of the invoke process by freeing allocated memory blocks.
+ # Most queue items take seconds to execute, so the relative cost of a GC is very small.
+ # Python will never cede allocated memory back to the OS, so anything we can do to reduce the peak
+ # allocation is well worth it.
+ gc.collect()
+
+ self._invoker.services.logger.info(
+ f"Executing queue item {worker.queue_item.item_id}, session {worker.queue_item.session_id} "
+ f"on {worker.label}"
+ )
- # Run the graph
- self.session_runner.run(queue_item=self._queue_item)
+ # Run the graph. Hold this GPU's exclusive-use lock for the whole session so no
+ # other worker can borrow it for text-encoder offload while we're running on it
+ # (a borrow + concurrent native session on one GPU would corrupt the shared
+ # cached encoder). Acquired here, after dequeue, so an idle worker doesn't hold
+ # the lock and block borrows while waiting for work.
+ GENERATION_DEVICE_POOL.acquire_session(worker.device)
+ try:
+ worker.runner.run(queue_item=worker.queue_item)
+ finally:
+ GENERATION_DEVICE_POOL.release_session(worker.device)
except Exception as e:
error_type = e.__class__.__name__
error_message = str(e)
error_traceback = traceback.format_exc()
self._on_non_fatal_processor_error(
- queue_item=self._queue_item,
+ queue_item=worker.queue_item,
error_type=error_type,
error_message=error_message,
error_traceback=error_traceback,
@@ -456,7 +652,7 @@ def _process(
poll_now_event.wait(self._polling_interval)
continue
except Exception as e:
- # Fatal error in processor, log and pass - we're done here
+ # Fatal error in this worker, log and pass - we're done here
error_type = e.__class__.__name__
error_message = str(e)
error_traceback = traceback.format_exc()
@@ -464,9 +660,9 @@ def _process(
self._invoker.services.logger.error(error_traceback)
pass
finally:
- stop_event.clear()
- poll_now_event.clear()
- self._queue_item = None
+ worker.queue_item = None
+ if worker.device is not None:
+ TorchDevice.clear_session_device()
self._thread_semaphore.release()
def _on_non_fatal_processor_error(
diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py
index 341e0344874..4ed4f1a62c1 100644
--- a/invokeai/app/services/session_queue/session_queue_base.py
+++ b/invokeai/app/services/session_queue/session_queue_base.py
@@ -1,36 +1,45 @@
from abc import ABC, abstractmethod
-from typing import Optional
+from typing import Any, Coroutine, Optional
from invokeai.app.services.session_queue.session_queue_common import (
QUEUE_ITEM_STATUS,
Batch,
BatchStatus,
+ CancelAllExceptCurrentResult,
CancelByBatchIDsResult,
+ CancelByDestinationResult,
CancelByQueueIDResult,
ClearResult,
+ DeleteAllExceptCurrentResult,
+ DeleteByDestinationResult,
EnqueueBatchResult,
IsEmptyResult,
IsFullResult,
+ ItemIdsResult,
PruneResult,
+ RetryItemsResult,
+ SessionQueueCountsByDestination,
SessionQueueItem,
- SessionQueueItemDTO,
SessionQueueStatus,
)
from invokeai.app.services.shared.graph import GraphExecutionState
from invokeai.app.services.shared.pagination import CursorPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
class SessionQueueBase(ABC):
"""Base class for session queue"""
@abstractmethod
- def dequeue(self) -> Optional[SessionQueueItem]:
- """Dequeues the next session queue item."""
+ def dequeue(self, device: Optional[str] = None) -> Optional[SessionQueueItem]:
+ """Dequeues the next session queue item, recording the processing device (e.g. 'cuda:1') if given."""
pass
@abstractmethod
- def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
- """Enqueues all permutations of a batch for execution."""
+ def enqueue_batch(
+ self, queue_id: str, batch: Batch, prepend: bool, user_id: str = "system"
+ ) -> Coroutine[Any, Any, EnqueueBatchResult]:
+ """Enqueues all permutations of a batch for execution for a specific user."""
pass
@abstractmethod
@@ -44,13 +53,13 @@ def get_next(self, queue_id: str) -> Optional[SessionQueueItem]:
pass
@abstractmethod
- def clear(self, queue_id: str) -> ClearResult:
- """Deletes all session queue items"""
+ def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult:
+ """Deletes all session queue items. If user_id is provided, only clears items owned by that user."""
pass
@abstractmethod
- def prune(self, queue_id: str) -> PruneResult:
- """Deletes all completed and errored session queue items"""
+ def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult:
+ """Deletes all completed and errored session queue items. If user_id is provided, only prunes items owned by that user."""
pass
@abstractmethod
@@ -64,13 +73,36 @@ def is_full(self, queue_id: str) -> IsFullResult:
pass
@abstractmethod
- def get_queue_status(self, queue_id: str) -> SessionQueueStatus:
- """Gets the status of the queue"""
+ def get_queue_status(
+ self,
+ queue_id: str,
+ user_id: Optional[str] = None,
+ acting_user_id: Optional[str] = None,
+ ) -> SessionQueueStatus:
+ """Gets the status of the queue.
+
+ Aggregate counts (pending/in_progress/.../total) are always global across all users.
+ If user_id is provided, the requesting user's own counts are additionally returned in
+ the user_pending/user_in_progress fields (left None otherwise).
+
+ acting_user_id is independent of user_id and controls only current-item redaction:
+ when set, the returned status omits item_id/session_id/batch_id unless the
+ currently-running item belongs to acting_user_id. The redaction is decided from the
+ same get_current() snapshot used to embed those identifiers, so it cannot race against
+ a concurrent state change.
+ """
pass
@abstractmethod
- def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus:
- """Gets the status of a batch"""
+ def get_counts_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> SessionQueueCountsByDestination:
+ """Gets the counts of queue items by destination. If user_id is provided, only counts that user's items."""
+ pass
+
+ @abstractmethod
+ def get_batch_status(self, queue_id: str, batch_id: str, user_id: Optional[str] = None) -> BatchStatus:
+ """Gets the status of a batch. If user_id is provided, only counts that user's items."""
pass
@abstractmethod
@@ -83,6 +115,11 @@ def cancel_queue_item(self, item_id: int) -> SessionQueueItem:
"""Cancels a session queue item"""
pass
+ @abstractmethod
+ def delete_queue_item(self, item_id: int) -> None:
+ """Deletes a session queue item"""
+ pass
+
@abstractmethod
def fail_queue_item(
self, item_id: int, error_type: str, error_message: str, error_traceback: str
@@ -91,8 +128,24 @@ def fail_queue_item(
pass
@abstractmethod
- def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult:
- """Cancels all queue items with matching batch IDs"""
+ def cancel_by_batch_ids(
+ self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None
+ ) -> CancelByBatchIDsResult:
+ """Cancels all queue items with matching batch IDs. If user_id is provided, only cancels items owned by that user."""
+ pass
+
+ @abstractmethod
+ def cancel_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> CancelByDestinationResult:
+ """Cancels all queue items with the given batch destination. If user_id is provided, only cancels items owned by that user."""
+ pass
+
+ @abstractmethod
+ def delete_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> DeleteByDestinationResult:
+ """Deletes all queue items with the given batch destination. If user_id is provided, only deletes items owned by that user."""
pass
@abstractmethod
@@ -100,6 +153,16 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
"""Cancels all queue items with matching queue ID"""
pass
+ @abstractmethod
+ def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult:
+ """Cancels all queue items except in-progress items. If user_id is provided, only cancels items owned by that user."""
+ pass
+
+ @abstractmethod
+ def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult:
+ """Deletes all queue items except in-progress items. If user_id is provided, only deletes items owned by that user."""
+ pass
+
@abstractmethod
def list_queue_items(
self,
@@ -108,16 +171,41 @@ def list_queue_items(
priority: int,
cursor: Optional[int] = None,
status: Optional[QUEUE_ITEM_STATUS] = None,
- ) -> CursorPaginatedResults[SessionQueueItemDTO]:
- """Gets a page of session queue items"""
+ destination: Optional[str] = None,
+ ) -> CursorPaginatedResults[SessionQueueItem]:
+ """Gets a page of session queue items. Do not remove."""
+ pass
+
+ @abstractmethod
+ def list_all_queue_items(
+ self,
+ queue_id: str,
+ destination: Optional[str] = None,
+ ) -> list[SessionQueueItem]:
+ """Gets all queue items that match the given parameters"""
+ pass
+
+ @abstractmethod
+ def get_queue_item_ids(
+ self,
+ queue_id: str,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ user_id: Optional[str] = None,
+ ) -> ItemIdsResult:
+ """Gets all queue item ids that match the given parameters. If user_id is provided, only returns items for that user."""
pass
@abstractmethod
def get_queue_item(self, item_id: int) -> SessionQueueItem:
- """Gets a session queue item by ID"""
+ """Gets a session queue item by ID for a given queue"""
pass
@abstractmethod
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
"""Sets the session for a session queue item. Use this to update the session state."""
pass
+
+ @abstractmethod
+ def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
+ """Retries the given queue items"""
+ pass
diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py
index 7f4601eba73..63324337aa1 100644
--- a/invokeai/app/services/session_queue/session_queue_common.py
+++ b/invokeai/app/services/session_queue/session_queue_common.py
@@ -1,7 +1,7 @@
import datetime
import json
from itertools import chain, product
-from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast
+from typing import Generator, Literal, Optional, TypeAlias, Union
from pydantic import (
AliasChoices,
@@ -15,7 +15,7 @@
)
from pydantic_core import to_jsonable_python
-from invokeai.app.invocations.baseinvocation import BaseInvocation
+from invokeai.app.invocations.fields import ImageField
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, NodeNotFoundError
from invokeai.app.services.workflow_records.workflow_records_common import (
WorkflowWithoutID,
@@ -51,11 +51,7 @@ class SessionQueueItemNotFoundError(ValueError):
# region Batch
-BatchDataType = Union[
- StrictStr,
- float,
- int,
-]
+BatchDataType = Union[StrictStr, float, int, ImageField]
class NodeFieldValue(BaseModel):
@@ -77,6 +73,14 @@ class BatchDatum(BaseModel):
class Batch(BaseModel):
batch_id: str = Field(default_factory=uuid_string, description="The ID of the batch")
+ origin: str | None = Field(
+ default=None,
+ description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
+ )
+ destination: str | None = Field(
+ default=None,
+ description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
+ )
data: Optional[BatchDataCollection] = Field(default=None, description="The batch data collection.")
graph: Graph = Field(description="The graph to initialize the session with")
workflow: Optional[WorkflowWithoutID] = Field(
@@ -103,8 +107,16 @@ def validate_types(cls, v: Optional[BatchDataCollection]):
return v
for batch_data_list in v:
for datum in batch_data_list:
+ if not datum.items:
+ continue
+
+ # Special handling for numbers - they can be mixed
+ # TODO(psyche): Update BatchDatum to have a `type` field to specify the type of the items, then we can have strict float and int fields
+ if all(isinstance(item, (int, float)) for item in datum.items):
+ continue
+
# Get the type of the first item in the list
- first_item_type = type(datum.items[0]) if datum.items else None
+ first_item_type = type(datum.items[0])
for item in datum.items:
if type(item) is not first_item_type:
raise BatchItemsTypeError("All items in a batch must have the same type")
@@ -124,20 +136,18 @@ def validate_unique_field_mappings(cls, v: Optional[BatchDataCollection]):
return v
@model_validator(mode="after")
- def validate_batch_nodes_and_edges(cls, values):
- batch_data_collection = cast(Optional[BatchDataCollection], values.data)
- if batch_data_collection is None:
- return values
- graph = cast(Graph, values.graph)
- for batch_data_list in batch_data_collection:
+ def validate_batch_nodes_and_edges(self):
+ if self.data is None:
+ return self
+ for batch_data_list in self.data:
for batch_data in batch_data_list:
try:
- node = cast(BaseInvocation, graph.get_node(batch_data.node_path))
+ node = self.graph.get_node(batch_data.node_path)
except NodeNotFoundError:
raise NodeNotFoundError(f"Node {batch_data.node_path} not found in graph")
- if batch_data.field_name not in node.model_fields:
+ if batch_data.field_name not in type(node).model_fields:
raise NodeNotFoundError(f"Field {batch_data.field_name} not found in node {batch_data.node_path}")
- return values
+ return self
@field_validator("graph")
def validate_graph(cls, v: Graph):
@@ -160,9 +170,18 @@ def validate_graph(cls, v: Graph):
# region Queue Items
DEFAULT_QUEUE_ID = "default"
+SYSTEM_USER_ID = "system" # Default user_id for system-generated queue items
QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"]
+
+class ItemIdsResult(BaseModel):
+ """Response containing ordered item ids with metadata for optimistic updates."""
+
+ item_ids: list[int] = Field(description="Ordered list of item ids")
+ total_count: int = Field(description="Total number of queue items matching the query")
+
+
NodeFieldValueValidator = TypeAdapter(list[NodeFieldValue])
@@ -188,13 +207,33 @@ def get_workflow(queue_item_dict: dict) -> Optional[WorkflowWithoutID]:
return None
-class SessionQueueItemWithoutGraph(BaseModel):
+class FieldIdentifier(BaseModel):
+ kind: Literal["input", "output"] = Field(description="The kind of field")
+ node_id: str = Field(description="The ID of the node")
+ field_name: str = Field(description="The name of the field")
+ user_label: str | None = Field(description="The user label of the field, if any")
+
+
+class SessionQueueItem(BaseModel):
"""Session queue item without the full graph. Used for serialization."""
item_id: int = Field(description="The identifier of the session queue item")
status: QUEUE_ITEM_STATUS = Field(default="pending", description="The status of this queue item")
+ status_sequence: int | None = Field(
+ default=None,
+ # Fallback for rows serialized before migration_28 added the DB-level default of 0.
+ description="A monotonically increasing version for this queue item's visible status lifecycle",
+ )
priority: int = Field(default=0, description="The priority of this queue item")
batch_id: str = Field(description="The ID of the batch associated with this queue item")
+ origin: str | None = Field(
+ default=None,
+ description="The origin of this queue item. This data is used by the frontend to determine how to handle results.",
+ )
+ destination: str | None = Field(
+ default=None,
+ description="The origin of this queue item. This data is used by the frontend to determine how to handle results",
+ )
session_id: str = Field(
description="The ID of the session associated with this queue item. The session doesn't exist in graph_executions until the queue item is executed."
)
@@ -210,38 +249,23 @@ class SessionQueueItemWithoutGraph(BaseModel):
started_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was started")
completed_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was completed")
queue_id: str = Field(description="The id of the queue with which this item is associated")
+ user_id: str = Field(default="system", description="The id of the user who created this queue item")
+ user_display_name: Optional[str] = Field(
+ default=None, description="The display name of the user who created this queue item, if available"
+ )
+ user_email: Optional[str] = Field(
+ default=None, description="The email of the user who created this queue item, if available"
+ )
field_values: Optional[list[NodeFieldValue]] = Field(
default=None, description="The field values that were used for this queue item"
)
-
- @classmethod
- def queue_item_dto_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItemDTO":
- # must parse these manually
- queue_item_dict["field_values"] = get_field_values(queue_item_dict)
- return SessionQueueItemDTO(**queue_item_dict)
-
- model_config = ConfigDict(
- json_schema_extra={
- "required": [
- "item_id",
- "status",
- "batch_id",
- "queue_id",
- "session_id",
- "priority",
- "session_id",
- "created_at",
- "updated_at",
- ]
- }
+ retried_from_item_id: Optional[int] = Field(
+ default=None, description="The item_id of the queue item that this item was retried from"
+ )
+ device: Optional[str] = Field(
+ default=None,
+ description="The device that processed this queue item, e.g. 'cuda:1' (set only when running on a CUDA GPU)",
)
-
-
-class SessionQueueItemDTO(SessionQueueItemWithoutGraph):
- pass
-
-
-class SessionQueueItem(SessionQueueItemWithoutGraph):
session: GraphExecutionState = Field(description="The fully-populated session to be executed")
workflow: Optional[WorkflowWithoutID] = Field(
default=None, description="The workflow associated with this queue item"
@@ -289,11 +313,32 @@ class SessionQueueStatus(BaseModel):
failed: int = Field(..., description="Number of queue items with status 'error'")
canceled: int = Field(..., description="Number of queue items with status 'canceled'")
total: int = Field(..., description="Total number of queue items")
+ user_pending: Optional[int] = Field(
+ default=None,
+ description="Number of the requesting user's queue items with status 'pending' (None for admins/global callers)",
+ )
+ user_in_progress: Optional[int] = Field(
+ default=None,
+ description="Number of the requesting user's queue items with status 'in_progress' (None for admins/global callers)",
+ )
+
+
+class SessionQueueCountsByDestination(BaseModel):
+ queue_id: str = Field(..., description="The ID of the queue")
+ destination: str = Field(..., description="The destination of queue items included in this status")
+ pending: int = Field(..., description="Number of queue items with status 'pending' for the destination")
+ in_progress: int = Field(..., description="Number of queue items with status 'in_progress' for the destination")
+ completed: int = Field(..., description="Number of queue items with status 'complete' for the destination")
+ failed: int = Field(..., description="Number of queue items with status 'error' for the destination")
+ canceled: int = Field(..., description="Number of queue items with status 'canceled' for the destination")
+ total: int = Field(..., description="Total number of queue items for the destination")
class BatchStatus(BaseModel):
queue_id: str = Field(..., description="The ID of the queue")
batch_id: str = Field(..., description="The ID of the batch")
+ origin: str | None = Field(..., description="The origin of the batch")
+ destination: str | None = Field(..., description="The destination of the batch")
pending: int = Field(..., description="Number of queue items with status 'pending'")
in_progress: int = Field(..., description="Number of queue items with status 'in_progress'")
completed: int = Field(..., description="Number of queue items with status 'complete'")
@@ -308,6 +353,12 @@ class EnqueueBatchResult(BaseModel):
requested: int = Field(description="The total number of queue items requested to be enqueued")
batch: Batch = Field(description="The batch that was enqueued")
priority: int = Field(description="The priority of the enqueued batch")
+ item_ids: list[int] = Field(description="The IDs of the queue items that were enqueued")
+
+
+class RetryItemsResult(BaseModel):
+ queue_id: str = Field(description="The ID of the queue")
+ retried_item_ids: list[int] = Field(description="The IDs of the queue items that were retried")
class ClearResult(BaseModel):
@@ -328,12 +379,36 @@ class CancelByBatchIDsResult(BaseModel):
canceled: int = Field(..., description="Number of queue items canceled")
+class CancelByDestinationResult(CancelByBatchIDsResult):
+ """Result of canceling by a destination"""
+
+ pass
+
+
+class DeleteByDestinationResult(BaseModel):
+ """Result of deleting by a destination"""
+
+ deleted: int = Field(..., description="Number of queue items deleted")
+
+
+class DeleteAllExceptCurrentResult(DeleteByDestinationResult):
+ """Result of deleting all except current"""
+
+ pass
+
+
class CancelByQueueIDResult(CancelByBatchIDsResult):
"""Result of canceling by queue id"""
pass
+class CancelAllExceptCurrentResult(CancelByBatchIDsResult):
+ """Result of canceling all except current"""
+
+ pass
+
+
class IsEmptyResult(BaseModel):
"""Result of checking if the session queue is empty"""
@@ -352,61 +427,143 @@ class IsFullResult(BaseModel):
# region Util
-def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph:
+def create_session_nfv_tuples(batch: Batch, maximum: int) -> Generator[tuple[str, str, str], None, None]:
"""
- Populates the given graph with the given batch data items.
- """
- graph_clone = graph.model_copy(deep=True)
- for item in node_field_values:
- node = graph_clone.get_node(item.node_path)
- if node is None:
- continue
- setattr(node, item.field_name, item.value)
- graph_clone.update_node(item.node_path, node)
- return graph_clone
-
-
-def create_session_nfv_tuples(
- batch: Batch, maximum: int
-) -> Generator[tuple[GraphExecutionState, list[NodeFieldValue], Optional[WorkflowWithoutID]], None, None]:
- """
- Create all graph permutations from the given batch data and graph. Yields tuples
- of the form (graph, batch_data_items) where batch_data_items is the list of BatchDataItems
- that was applied to the graph.
+ Given a batch and a maximum number of sessions to create, generate a tuple of session_id, session_json, and
+ field_values_json for each session.
+
+ The batch has a "source" graph and a data property. The data property is a list of lists of BatchDatum objects.
+ Each BatchDatum has a field identifier (e.g. a node id and field name), and a list of values to substitute into
+ the field.
+
+ This structure allows us to create a new graph for every possible permutation of BatchDatum objects:
+ - Each BatchDatum can be "expanded" into a dict of node-field-value tuples - one for each item in the BatchDatum.
+ - Zip each inner list of expanded BatchDatum objects together. Call this a "batch_data_list".
+ - Take the cartesian product of all zipped batch_data_lists, resulting in a list of permutations of BatchDatum
+ - Take the cartesian product of all zipped batch_data_lists, resulting in a list of lists of BatchDatum objects.
+ Each inner list now represents the substitution values for a single permutation (session).
+ - For each permutation, substitute the values into the graph
+
+ This function is optimized for performance, as it is used to generate a large number of sessions at once.
+
+ Args:
+ batch: The batch to generate sessions from
+ maximum: The maximum number of sessions to generate
+
+ Returns:
+ A generator that yields tuples of session_id, session_json, and field_values_json for each session. The
+ generator will stop early if the maximum number of sessions is reached.
"""
# TODO: Should this be a class method on Batch?
- data: list[list[tuple[NodeFieldValue]]] = []
+ data: list[list[tuple[dict]]] = []
batch_data_collection = batch.data if batch.data is not None else []
- for batch_datum_list in batch_data_collection:
- # each batch_datum_list needs to be convered to NodeFieldValues and then zipped
- node_field_values_to_zip: list[list[NodeFieldValue]] = []
+ for batch_datum_list in batch_data_collection:
+ node_field_values_to_zip: list[list[dict]] = []
+ # Expand each BatchDatum into a list of dicts - one for each item in the BatchDatum
for batch_datum in batch_datum_list:
node_field_values = [
- NodeFieldValue(node_path=batch_datum.node_path, field_name=batch_datum.field_name, value=item)
+ # Note: A tuple here is slightly faster than a dict, but we need the object in dict form to be inserted
+ # in the session_queue table anyways. So, overall creating NFVs as dicts is faster.
+ {"node_path": batch_datum.node_path, "field_name": batch_datum.field_name, "value": item}
for item in batch_datum.items
]
node_field_values_to_zip.append(node_field_values)
+ # Zip the dicts together to create a list of dicts for each permutation
data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type]
- # create generator to yield session,nfv tuples
+ # We serialize the graph and session once, then mutate the graph dict in place for each session.
+ #
+ # This sounds scary, but it's actually fine.
+ #
+ # The batch prep logic injects field values into the same fields for each generated session.
+ #
+ # For example, after the product operation, we'll end up with a list of node-field-value tuples like this:
+ # [
+ # (
+ # {"node_path": "1", "field_name": "a", "value": 1},
+ # {"node_path": "2", "field_name": "b", "value": 2},
+ # {"node_path": "3", "field_name": "c", "value": 3},
+ # ),
+ # (
+ # {"node_path": "1", "field_name": "a", "value": 4},
+ # {"node_path": "2", "field_name": "b", "value": 5},
+ # {"node_path": "3", "field_name": "c", "value": 6},
+ # )
+ # ]
+ #
+ # Note that each tuple has the same length, and each tuple substitutes values in for exactly the same node fields.
+ # No matter the complexity of the batch, this property holds true.
+ #
+ # This means each permutation's substitution can be done in-place on the same graph dict, because it overwrites the
+ # previous mutation. We only need to serialize the graph once, and then we can mutate it in place for each session.
+ #
+ # Previously, we had created new Graph objects for each session, but this was very slow for large (1k+ session
+ # batches). We then tried dumping the graph to dict and using deep-copy to create a new dict for each session,
+ # but this was also slow.
+ #
+ # Overall, we achieved a 100x speedup by mutating the graph dict in place for each session over creating new Graph
+ # objects for each session.
+ #
+ # We will also mutate the session dict in place, setting a new ID for each session and setting the mutated graph
+ # dict as the session's graph.
+
+ # Dump the batch's graph to a dict once
+ graph_as_dict = batch.graph.model_dump(warnings=False, exclude_none=True)
+
+ # We must provide a Graph object when creating the "dummy" session dict, but we don't actually use it. It will be
+ # overwritten for each session by the mutated graph_as_dict.
+ session_dict = GraphExecutionState(graph=Graph()).model_dump(warnings=False, exclude_none=True)
+
+ # Now we can create a generator that yields the session_id, session_json, and field_values_json for each session.
count = 0
+
+ # Each batch may have multiple runs, so we need to generate the same number of sessions for each run. The total is
+ # still limited by the maximum number of sessions.
for _ in range(batch.runs):
for d in product(*data):
if count >= maximum:
+ # We've reached the maximum number of sessions we may generate
return
+
+ # Flatten the list of lists of dicts into a single list of dicts
+ # TODO(psyche): Is the a more efficient way to do this?
flat_node_field_values = list(chain.from_iterable(d))
- graph = populate_graph(batch.graph, flat_node_field_values)
- yield (GraphExecutionState(graph=graph), flat_node_field_values, batch.workflow)
+
+ # Need a fresh ID for each session
+ session_id = uuid_string()
+
+ # Mutate the session dict in place
+ session_dict["id"] = session_id
+
+ # Substitute the values into the graph
+ for nfv in flat_node_field_values:
+ graph_as_dict["nodes"][nfv["node_path"]][nfv["field_name"]] = nfv["value"]
+
+ # Mutate the session dict in place
+ session_dict["graph"] = graph_as_dict
+
+ # Serialize the session and field values
+ # Note the use of pydantic's to_jsonable_python to handle serialization of any python object, including sets.
+ session_json = json.dumps(session_dict, default=to_jsonable_python)
+ field_values_json = json.dumps(flat_node_field_values, default=to_jsonable_python)
+
+ # Yield the session_id, session_json, and field_values_json
+ yield (session_id, session_json, field_values_json)
+
+ # Increment the count so we know when to stop
count += 1
def calc_session_count(batch: Batch) -> int:
"""
- Calculates the number of sessions that would be created by the batch, without incurring
- the overhead of actually generating them. Adapted from `create_sessions().
+ Calculates the number of sessions that would be created by the batch, without incurring the overhead of actually
+ creating them, as is done in `create_session_nfv_tuples()`.
+
+ The count is used to communicate to the user how many sessions were _requested_ to be created, as opposed to how
+ many were _actually_ created (which may be less due to the maximum number of sessions).
"""
# TODO: Should this be a class method on Batch?
if not batch.data:
@@ -422,37 +579,82 @@ def calc_session_count(batch: Batch) -> int:
return len(data_product) * batch.runs
-class SessionQueueValueToInsert(NamedTuple):
- """A tuple of values to insert into the session_queue table"""
+ValueToInsertTuple: TypeAlias = tuple[
+ str, # queue_id
+ str, # session (as stringified JSON)
+ str, # session_id
+ str, # batch_id
+ str | None, # field_values (optional, as stringified JSON)
+ int, # priority
+ str | None, # workflow (optional, as stringified JSON)
+ str | None, # origin (optional)
+ str | None, # destination (optional)
+ int | None, # retried_from_item_id (optional, this is always None for new items)
+ str, # user_id
+]
+"""A type alias for the tuple of values to insert into the session queue table.
+
+**If you change this, be sure to update the `enqueue_batch` and `retry_items_by_id` methods in the session queue service!**
+"""
+
+
+def prepare_values_to_insert(
+ queue_id: str, batch: Batch, priority: int, max_new_queue_items: int, user_id: str = "system"
+) -> list[ValueToInsertTuple]:
+ """
+ Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an
+ `executemany` statement to insert multiple rows at once.
+
+ Args:
+ queue_id: The ID of the queue to insert the items into
+ batch: The batch to prepare the values for
+ priority: The priority of the queue items
+ max_new_queue_items: The maximum number of queue items to insert
+ user_id: The user ID who is creating these queue items
+
+ Returns:
+ A list of tuples to insert into the session queue table. Each tuple contains the following values:
+ - queue_id
+ - session (as stringified JSON)
+ - session_id
+ - batch_id
+ - field_values (optional, as stringified JSON)
+ - priority
+ - workflow (optional, as stringified JSON)
+ - origin (optional)
+ - destination (optional)
+ - retried_from_item_id (optional, this is always None for new items)
+ - user_id
+ """
- # Careful with the ordering of this - it must match the insert statement
- queue_id: str # queue_id
- session: str # session json
- session_id: str # session_id
- batch_id: str # batch_id
- field_values: Optional[str] # field_values json
- priority: int # priority
- workflow: Optional[str] # workflow json
+ # A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but
+ # measured a ~5% performance improvement by using a normal tuple instead. For very large batches (10k+ items), the
+ # this difference becomes noticeable.
+ #
+ # So, despite the inferior DX with normal tuples, we use one here for performance reasons.
+ values_to_insert: list[ValueToInsertTuple] = []
-ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert]
+ # pydantic's to_jsonable_python handles serialization of any python object, including sets, which json.dumps does
+ # not support by default. Apparently there are sets somewhere in the graph.
+ # The same workflow is used for all sessions in the batch - serialize it once
+ workflow_json = json.dumps(batch.workflow, default=to_jsonable_python) if batch.workflow else None
-def prepare_values_to_insert(queue_id: str, batch: Batch, priority: int, max_new_queue_items: int) -> ValuesToInsert:
- values_to_insert: ValuesToInsert = []
- for session, field_values, workflow in create_session_nfv_tuples(batch, max_new_queue_items):
- # sessions must have unique id
- session.id = uuid_string()
+ for session_id, session_json, field_values_json in create_session_nfv_tuples(batch, max_new_queue_items):
values_to_insert.append(
- SessionQueueValueToInsert(
- queue_id, # queue_id
- session.model_dump_json(warnings=False, exclude_none=True), # session (json)
- session.id, # session_id
- batch.batch_id, # batch_id
- # must use pydantic_encoder bc field_values is a list of models
- json.dumps(field_values, default=to_jsonable_python) if field_values else None, # field_values (json)
- priority, # priority
- json.dumps(workflow, default=to_jsonable_python) if workflow else None, # workflow (json)
+ (
+ queue_id,
+ session_json,
+ session_id,
+ batch.batch_id,
+ field_values_json,
+ priority,
+ workflow_json,
+ batch.origin,
+ batch.destination,
+ None,
+ user_id,
)
)
return values_to_insert
diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py
index a3a7004c947..e6d8229860d 100644
--- a/invokeai/app/services/session_queue/session_queue_sqlite.py
+++ b/invokeai/app/services/session_queue/session_queue_sqlite.py
@@ -1,6 +1,10 @@
+import asyncio
+import json
import sqlite3
import threading
-from typing import Optional, Union, cast
+from typing import Any, Optional, Union, cast
+
+from pydantic_core import to_jsonable_python
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase
@@ -9,218 +13,282 @@
QUEUE_ITEM_STATUS,
Batch,
BatchStatus,
+ CancelAllExceptCurrentResult,
CancelByBatchIDsResult,
+ CancelByDestinationResult,
CancelByQueueIDResult,
ClearResult,
+ DeleteAllExceptCurrentResult,
+ DeleteByDestinationResult,
EnqueueBatchResult,
IsEmptyResult,
IsFullResult,
+ ItemIdsResult,
PruneResult,
+ RetryItemsResult,
+ SessionQueueCountsByDestination,
SessionQueueItem,
- SessionQueueItemDTO,
SessionQueueItemNotFoundError,
SessionQueueStatus,
+ ValueToInsertTuple,
calc_session_count,
prepare_values_to_insert,
)
from invokeai.app.services.shared.graph import GraphExecutionState
from invokeai.app.services.shared.pagination import CursorPaginatedResults
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
class SqliteSessionQueue(SessionQueueBase):
__invoker: Invoker
- __conn: sqlite3.Connection
- __cursor: sqlite3.Cursor
- __lock: threading.RLock
+
+ # Serializes the select-candidate-then-claim sequence in `dequeue()`. The DB connection's
+ # RLock serializes individual statements, but the gap between selecting the next pending item
+ # and marking it 'in_progress' is a race: with multiple session-processor workers (multi-GPU),
+ # two workers could select the same item. Holding this lock across the whole claim prevents it.
+ _dequeue_lock = threading.Lock()
def start(self, invoker: Invoker) -> None:
self.__invoker = invoker
self._set_in_progress_to_canceled()
- if self.__invoker.services.configuration.clear_queue_on_startup:
+ config = self.__invoker.services.configuration
+ if config.clear_queue_on_startup:
clear_result = self.clear(DEFAULT_QUEUE_ID)
if clear_result.deleted > 0:
self.__invoker.services.logger.info(f"Cleared all {clear_result.deleted} queue items")
- else:
- prune_result = self.prune(DEFAULT_QUEUE_ID)
- if prune_result.deleted > 0:
- self.__invoker.services.logger.info(f"Pruned {prune_result.deleted} finished queue items")
+ return
+
+ if config.max_queue_history is not None:
+ deleted = self._prune_terminal_to_limit(DEFAULT_QUEUE_ID, config.max_queue_history)
+ if deleted > 0:
+ self.__invoker.services.logger.info(
+ f"Pruned {deleted} completed/failed/canceled queue items (kept up to {config.max_queue_history})"
+ )
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
- self.__lock = db.lock
- self.__conn = db.conn
- self.__cursor = self.__conn.cursor()
+ self._db = db
def _set_in_progress_to_canceled(self) -> None:
"""
Sets all in_progress queue items to canceled. Run on app startup, not associated with any queue.
This is necessary because the invoker may have been killed while processing a queue item.
"""
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
UPDATE session_queue
- SET status = 'canceled'
+ SET status = 'canceled',
+ status_sequence = COALESCE(status_sequence, 0) + 1
WHERE status = 'in_progress';
"""
)
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+
+ def _prune_terminal_to_limit(self, queue_id: str, keep: int) -> int:
+ """Prune terminal items (completed/failed/canceled) to keep at most N most-recent items."""
+ with self._db.transaction() as cursor:
+ where = """--sql
+ WHERE
+ queue_id = ?
+ AND (
+ status = 'completed'
+ OR status = 'failed'
+ OR status = 'canceled'
+ )
+ """
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM session_queue
+ {where}
+ AND item_id NOT IN (
+ SELECT item_id
+ FROM session_queue
+ {where}
+ ORDER BY COALESCE(completed_at, updated_at, created_at) DESC, item_id DESC
+ LIMIT ?
+ );
+ """,
+ (queue_id, queue_id, keep),
+ )
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
+ DELETE
+ FROM session_queue
+ {where}
+ AND item_id NOT IN (
+ SELECT item_id
+ FROM session_queue
+ {where}
+ ORDER BY COALESCE(completed_at, updated_at, created_at) DESC, item_id DESC
+ LIMIT ?
+ );
+ """,
+ (queue_id, queue_id, keep),
+ )
+ return count
def _get_current_queue_size(self, queue_id: str) -> int:
"""Gets the current number of pending queue items"""
- self.__cursor.execute(
- """--sql
- SELECT count(*)
- FROM session_queue
- WHERE
- queue_id = ?
- AND status = 'pending'
- """,
- (queue_id,),
- )
- return cast(int, self.__cursor.fetchone()[0])
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT count(*)
+ FROM session_queue
+ WHERE
+ queue_id = ?
+ AND status = 'pending'
+ """,
+ (queue_id,),
+ )
+ count = cast(int, cursor.fetchone()[0])
+ return count
def _get_highest_priority(self, queue_id: str) -> int:
"""Gets the highest priority value in the queue"""
- self.__cursor.execute(
- """--sql
- SELECT MAX(priority)
- FROM session_queue
- WHERE
- queue_id = ?
- AND status = 'pending'
- """,
- (queue_id,),
- )
- return cast(Union[int, None], self.__cursor.fetchone()[0]) or 0
-
- def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult:
- try:
- self.__lock.acquire()
-
- # TODO: how does this work in a multi-user scenario?
- current_queue_size = self._get_current_queue_size(queue_id)
- max_queue_size = self.__invoker.services.configuration.max_queue_size
- max_new_queue_items = max_queue_size - current_queue_size
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT MAX(priority)
+ FROM session_queue
+ WHERE
+ queue_id = ?
+ AND status = 'pending'
+ """,
+ (queue_id,),
+ )
+ priority = cast(Union[int, None], cursor.fetchone()[0]) or 0
+ return priority
- priority = 0
- if prepend:
- priority = self._get_highest_priority(queue_id) + 1
+ async def enqueue_batch(
+ self, queue_id: str, batch: Batch, prepend: bool, user_id: str = "system"
+ ) -> EnqueueBatchResult:
+ current_queue_size = self._get_current_queue_size(queue_id)
+ max_queue_size = self.__invoker.services.configuration.max_queue_size
+ max_new_queue_items = max_queue_size - current_queue_size
- requested_count = calc_session_count(batch)
- values_to_insert = prepare_values_to_insert(
- queue_id=queue_id,
- batch=batch,
- priority=priority,
- max_new_queue_items=max_new_queue_items,
- )
- enqueued_count = len(values_to_insert)
+ priority = 0
+ if prepend:
+ priority = self._get_highest_priority(queue_id) + 1
- if requested_count > enqueued_count:
- values_to_insert = values_to_insert[:max_new_queue_items]
+ requested_count = await asyncio.to_thread(
+ calc_session_count,
+ batch=batch,
+ )
+ values_to_insert = await asyncio.to_thread(
+ prepare_values_to_insert,
+ queue_id=queue_id,
+ batch=batch,
+ priority=priority,
+ max_new_queue_items=max_new_queue_items,
+ user_id=user_id,
+ )
+ enqueued_count = len(values_to_insert)
- self.__cursor.executemany(
+ with self._db.transaction() as cursor:
+ cursor.executemany(
"""--sql
- INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
+ INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
values_to_insert,
)
- self.__conn.commit()
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ cursor.execute(
+ """--sql
+ SELECT item_id
+ FROM session_queue
+ WHERE batch_id = ?
+ ORDER BY item_id DESC;
+ """,
+ (batch.batch_id,),
+ )
+ item_ids = [row[0] for row in cursor.fetchall()]
enqueue_result = EnqueueBatchResult(
queue_id=queue_id,
requested=requested_count,
enqueued=enqueued_count,
batch=batch,
priority=priority,
+ item_ids=item_ids,
)
- self.__invoker.services.events.emit_batch_enqueued(enqueue_result)
+ self.__invoker.services.events.emit_batch_enqueued(enqueue_result, user_id=user_id)
return enqueue_result
- def dequeue(self) -> Optional[SessionQueueItem]:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
- """--sql
- SELECT *
- FROM session_queue
- WHERE status = 'pending'
- ORDER BY
- priority DESC,
- item_id ASC
- LIMIT 1
- """
- )
- result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
- if result is None:
- return None
- queue_item = SessionQueueItem.queue_item_from_dict(dict(result))
- queue_item = self._set_queue_item_status(item_id=queue_item.item_id, status="in_progress")
+ def dequeue(self, device: Optional[str] = None) -> Optional[SessionQueueItem]:
+ # Hold the dequeue lock across the select-then-claim so concurrent workers (multi-GPU)
+ # cannot select and claim the same pending item. `_set_queue_item_status` already no-ops
+ # if the item was concurrently moved to a terminal state (e.g. canceled), so we only need
+ # to guard against two dequeues racing for the same pending row.
+ with self._dequeue_lock:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT
+ sq.*,
+ u.display_name as user_display_name,
+ u.email as user_email
+ FROM session_queue sq
+ LEFT JOIN users u ON sq.user_id = u.user_id
+ WHERE sq.status = 'pending'
+ ORDER BY
+ sq.priority DESC,
+ sq.item_id ASC
+ LIMIT 1
+ """
+ )
+ result = cast(Union[sqlite3.Row, None], cursor.fetchone())
+ if result is None:
+ return None
+ queue_item = SessionQueueItem.queue_item_from_dict(dict(result))
+ # Record the claiming worker's device so the UI can label the item by GPU.
+ queue_item = self._set_queue_item_status(item_id=queue_item.item_id, status="in_progress", device=device)
return queue_item
def get_next(self, queue_id: str) -> Optional[SessionQueueItem]:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT *
- FROM session_queue
+ SELECT
+ sq.*,
+ u.display_name as user_display_name,
+ u.email as user_email
+ FROM session_queue sq
+ LEFT JOIN users u ON sq.user_id = u.user_id
WHERE
- queue_id = ?
- AND status = 'pending'
+ sq.queue_id = ?
+ AND sq.status = 'pending'
ORDER BY
- priority DESC,
- created_at ASC
+ sq.priority DESC,
+ sq.created_at ASC
LIMIT 1
""",
(queue_id,),
)
- result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ result = cast(Union[sqlite3.Row, None], cursor.fetchone())
if result is None:
return None
return SessionQueueItem.queue_item_from_dict(dict(result))
def get_current(self, queue_id: str) -> Optional[SessionQueueItem]:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT *
- FROM session_queue
+ SELECT
+ sq.*,
+ u.display_name as user_display_name,
+ u.email as user_email
+ FROM session_queue sq
+ LEFT JOIN users u ON sq.user_id = u.user_id
WHERE
- queue_id = ?
- AND status = 'in_progress'
+ sq.queue_id = ?
+ AND sq.status = 'in_progress'
LIMIT 1
""",
(queue_id,),
)
- result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ result = cast(Union[sqlite3.Row, None], cursor.fetchone())
if result is None:
return None
return SessionQueueItem.queue_item_from_dict(dict(result))
@@ -232,33 +300,50 @@ def _set_queue_item_status(
error_type: Optional[str] = None,
error_message: Optional[str] = None,
error_traceback: Optional[str] = None,
+ device: Optional[str] = None,
) -> SessionQueueItem:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT status FROM session_queue WHERE item_id = ?
+ """,
+ (item_id,),
+ )
+ row = cursor.fetchone()
+ if row is None:
+ raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}")
+ current_status = row[0]
+
+ # Only update if not already finished (completed, failed or canceled)
+ if current_status in ("completed", "failed", "canceled"):
+ return self.get_queue_item(item_id)
+
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
UPDATE session_queue
- SET status = ?, error_type = ?, error_message = ?, error_traceback = ?
+ SET status = ?, status_sequence = COALESCE(status_sequence, 0) + 1, error_type = ?, error_message = ?, error_traceback = ?, device = COALESCE(?, device)
WHERE item_id = ?
""",
- (status, error_type, error_message, error_traceback, item_id),
+ (status, error_type, error_message, error_traceback, device, item_id),
)
- self.__conn.commit()
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+
queue_item = self.get_queue_item(item_id)
batch_status = self.get_batch_status(queue_id=queue_item.queue_id, batch_id=queue_item.batch_id)
- queue_status = self.get_queue_status(queue_id=queue_item.queue_id)
+ # The QueueItemStatusChangedEvent ships to user:{queue_item.user_id} and admin rooms.
+ # acting_user_id ensures the embedded current-item identifiers are redacted when the
+ # in-progress item belongs to someone else, while leaving aggregate counts global.
+ # Doing this inside get_queue_status guarantees the redaction decision and the
+ # embedded identifiers come from the same get_current() snapshot — eliminating the
+ # race where a second read could find None and skip scrubbing stale identifiers.
+ queue_status = self.get_queue_status(queue_id=queue_item.queue_id, acting_user_id=queue_item.user_id)
+
self.__invoker.services.events.emit_queue_item_status_changed(queue_item, batch_status, queue_status)
return queue_item
def is_empty(self, queue_id: str) -> IsEmptyResult:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
SELECT count(*)
FROM session_queue
@@ -266,18 +351,12 @@ def is_empty(self, queue_id: str) -> IsEmptyResult:
""",
(queue_id,),
)
- is_empty = cast(int, self.__cursor.fetchone()[0]) == 0
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ is_empty = cast(int, cursor.fetchone()[0]) == 0
return IsEmptyResult(is_empty=is_empty)
def is_full(self, queue_id: str) -> IsFullResult:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
SELECT count(*)
FROM session_queue
@@ -286,84 +365,96 @@ def is_full(self, queue_id: str) -> IsFullResult:
(queue_id,),
)
max_queue_size = self.__invoker.services.configuration.max_queue_size
- is_full = cast(int, self.__cursor.fetchone()[0]) >= max_queue_size
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ is_full = cast(int, cursor.fetchone()[0]) >= max_queue_size
return IsFullResult(is_full=is_full)
- def clear(self, queue_id: str) -> ClearResult:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
- """--sql
+ def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult:
+ with self._db.transaction() as cursor:
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ where = f"""--sql
+ WHERE queue_id = ?
+ {user_filter}
+ """
+ params: list[str] = [queue_id]
+ if user_id is not None:
+ params.append(user_id)
+ cursor.execute(
+ f"""--sql
SELECT COUNT(*)
FROM session_queue
- WHERE queue_id = ?
+ {where}
""",
- (queue_id,),
+ tuple(params),
)
- count = self.__cursor.fetchone()[0]
- self.__cursor.execute(
- """--sql
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
DELETE
FROM session_queue
- WHERE queue_id = ?
+ {where}
""",
- (queue_id,),
+ tuple(params),
)
- self.__conn.commit()
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
self.__invoker.services.events.emit_queue_cleared(queue_id)
return ClearResult(deleted=count)
- def prune(self, queue_id: str) -> PruneResult:
- try:
- where = """--sql
+ def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult:
+ with self._db.transaction() as cursor:
+ # Build WHERE clause with optional user_id filter
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ where = f"""--sql
WHERE
- queue_id = ?
- AND (
+ queue_id = ?
+ AND (
status = 'completed'
OR status = 'failed'
OR status = 'canceled'
- )
+ )
+ {user_filter}
"""
- self.__lock.acquire()
- self.__cursor.execute(
+ params = [queue_id]
+ if user_id is not None:
+ params.append(user_id)
+
+ cursor.execute(
f"""--sql
SELECT COUNT(*)
FROM session_queue
{where};
""",
- (queue_id,),
+ tuple(params),
)
- count = self.__cursor.fetchone()[0]
- self.__cursor.execute(
+ count = cursor.fetchone()[0]
+ cursor.execute(
f"""--sql
DELETE
FROM session_queue
{where};
""",
- (queue_id,),
+ tuple(params),
)
- self.__conn.commit()
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
return PruneResult(deleted=count)
def cancel_queue_item(self, item_id: int) -> SessionQueueItem:
queue_item = self._set_queue_item_status(item_id=item_id, status="canceled")
return queue_item
+ def delete_queue_item(self, item_id: int) -> None:
+ """Deletes a session queue item"""
+ try:
+ self.cancel_queue_item(item_id)
+ except SessionQueueItemNotFoundError:
+ pass
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ DELETE
+ FROM session_queue
+ WHERE item_id = ?
+ """,
+ (item_id,),
+ )
+
def complete_queue_item(self, item_id: int) -> SessionQueueItem:
queue_item = self._set_queue_item_status(item_id=item_id, status="completed")
return queue_item
@@ -384,21 +475,62 @@ def fail_queue_item(
)
return queue_item
- def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult:
- try:
- current_queue_item = self.get_current(queue_id)
- self.__lock.acquire()
- placeholders = ", ".join(["?" for _ in batch_ids])
+ def _cancel_in_progress_matching(self, match_filter: str, params: list[Any]) -> int:
+ """Cancel every in-progress item matching `match_filter`, emitting a cancel event for each.
+
+ The bulk-cancel methods exclude in-progress items from their single UPDATE statement, because
+ a running item must be canceled via `_set_queue_item_status()` so that its
+ `QueueItemStatusChangedEvent` is emitted — the session processor responds to that event by
+ setting the cancel event of the worker running that exact item_id. With multiple workers
+ (multi-GPU) more than one item can be in_progress at once, so each matching item is canceled
+ individually here rather than relying on a single `get_current()` (which returns only one).
+
+ `match_filter` is a WHERE fragment without the leading WHERE (e.g.
+ "queue_id == ? AND batch_id IN (?, ?)"); `params` are its bound values.
+
+ Returns the number of in-progress items actually canceled.
+ """
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ f"""--sql
+ SELECT item_id
+ FROM session_queue
+ WHERE status == 'in_progress' AND {match_filter};
+ """,
+ tuple(params),
+ )
+ item_ids = [row[0] for row in cursor.fetchall()]
+
+ canceled = 0
+ for item_id in item_ids:
+ # _set_queue_item_status no-ops (and returns the existing item) if the item finished
+ # between the SELECT and now, so count only the ones we actually moved to 'canceled'.
+ if self._set_queue_item_status(item_id, "canceled").status == "canceled":
+ canceled += 1
+ return canceled
+
+ def cancel_by_batch_ids(
+ self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None
+ ) -> CancelByBatchIDsResult:
+ placeholders = ", ".join(["?" for _ in batch_ids])
+ # Build the match filter (with optional user_id filter) shared by the bulk update and the
+ # in-progress cancellation below.
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ match_filter = f"queue_id == ? AND batch_id IN ({placeholders}) {user_filter}"
+ params: list[Any] = [queue_id] + batch_ids
+ if user_id is not None:
+ params.append(user_id)
+
+ with self._db.transaction() as cursor:
where = f"""--sql
- WHERE
- queue_id == ?
- AND batch_id IN ({placeholders})
+ WHERE {match_filter}
AND status != 'canceled'
AND status != 'completed'
AND status != 'failed'
+ -- In-progress items are canceled individually below so each worker is signaled.
+ AND status != 'in_progress'
"""
- params = [queue_id] + batch_ids
- self.__cursor.execute(
+ cursor.execute(
f"""--sql
SELECT COUNT(*)
FROM session_queue
@@ -406,42 +538,149 @@ def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBa
""",
tuple(params),
)
- count = self.__cursor.fetchone()[0]
- self.__cursor.execute(
+ count = cursor.fetchone()[0]
+ cursor.execute(
f"""--sql
UPDATE session_queue
- SET status = 'canceled'
+ SET status = 'canceled',
+ status_sequence = COALESCE(status_sequence, 0) + 1
{where};
""",
tuple(params),
)
- self.__conn.commit()
- if current_queue_item is not None and current_queue_item.batch_id in batch_ids:
- batch_status = self.get_batch_status(queue_id=queue_id, batch_id=current_queue_item.batch_id)
- queue_status = self.get_queue_status(queue_id=queue_id)
- self.__invoker.services.events.emit_queue_item_status_changed(
- current_queue_item, batch_status, queue_status
- )
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+
+ # Cancel every in-progress item matching the same filter (multi-GPU: possibly several at once).
+ count += self._cancel_in_progress_matching(match_filter, params)
return CancelByBatchIDsResult(canceled=count)
- def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
- try:
- current_queue_item = self.get_current(queue_id)
- self.__lock.acquire()
- where = """--sql
- WHERE
- queue_id is ?
+ def cancel_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> CancelByDestinationResult:
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ match_filter = f"queue_id == ? AND destination == ? {user_filter}"
+ params: list[Any] = [queue_id, destination]
+ if user_id is not None:
+ params.append(user_id)
+
+ with self._db.transaction() as cursor:
+ where = f"""--sql
+ WHERE {match_filter}
AND status != 'canceled'
AND status != 'completed'
AND status != 'failed'
+ -- In-progress items are canceled individually below so each worker is signaled.
+ AND status != 'in_progress'
+ """
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM session_queue
+ {where};
+ """,
+ tuple(params),
+ )
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
+ UPDATE session_queue
+ SET status = 'canceled',
+ status_sequence = COALESCE(status_sequence, 0) + 1
+ {where};
+ """,
+ tuple(params),
+ )
+
+ # Cancel every in-progress item matching the same filter (multi-GPU: possibly several at once).
+ count += self._cancel_in_progress_matching(match_filter, params)
+ return CancelByDestinationResult(canceled=count)
+
+ def delete_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> DeleteByDestinationResult:
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ match_filter = f"queue_id == ? AND destination == ? {user_filter}"
+ params: list[Any] = [queue_id, destination]
+ if user_id is not None:
+ params.append(user_id)
+
+ # Cancel every in-progress item first so each running worker is signaled to stop before we
+ # delete its row. With multiple workers (multi-GPU) more than one item can be in_progress;
+ # canceling only get_current() would leave the others running (and then failing to update a
+ # deleted row). See _cancel_in_progress_matching.
+ self._cancel_in_progress_matching(match_filter, params)
+
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM session_queue
+ WHERE
+ queue_id == ?
+ AND destination == ?
+ {user_filter}
+ """,
+ tuple(params),
+ )
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
+ DELETE FROM session_queue
+ WHERE
+ queue_id == ?
+ AND destination == ?
+ {user_filter}
+ """,
+ tuple(params),
+ )
+ return DeleteByDestinationResult(deleted=count)
+
+ def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult:
+ with self._db.transaction() as cursor:
+ # Build WHERE clause with optional user_id filter
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ where = f"""--sql
+ WHERE
+ queue_id == ?
+ AND status == 'pending'
+ {user_filter}
"""
params = [queue_id]
- self.__cursor.execute(
+ if user_id is not None:
+ params.append(user_id)
+
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM session_queue
+ {where};
+ """,
+ tuple(params),
+ )
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
+ DELETE
+ FROM session_queue
+ {where};
+ """,
+ tuple(params),
+ )
+ return DeleteAllExceptCurrentResult(deleted=count)
+
+ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
+ match_filter = "queue_id == ?"
+ params: list[Any] = [queue_id]
+
+ with self._db.transaction() as cursor:
+ where = f"""--sql
+ WHERE {match_filter}
+ AND status != 'canceled'
+ AND status != 'completed'
+ AND status != 'failed'
+ -- In-progress items are canceled individually below so each worker is signaled.
+ AND status != 'in_progress'
+ """
+ cursor.execute(
f"""--sql
SELECT COUNT(*)
FROM session_queue
@@ -449,58 +688,81 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult:
""",
tuple(params),
)
- count = self.__cursor.fetchone()[0]
- self.__cursor.execute(
+ count = cursor.fetchone()[0]
+ cursor.execute(
f"""--sql
UPDATE session_queue
- SET status = 'canceled'
+ SET status = 'canceled',
+ status_sequence = COALESCE(status_sequence, 0) + 1
{where};
""",
tuple(params),
)
- self.__conn.commit()
- if current_queue_item is not None and current_queue_item.queue_id == queue_id:
- batch_status = self.get_batch_status(queue_id=queue_id, batch_id=current_queue_item.batch_id)
- queue_status = self.get_queue_status(queue_id=queue_id)
- self.__invoker.services.events.emit_queue_item_status_changed(
- current_queue_item, batch_status, queue_status
- )
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+
+ # Cancel every in-progress item in the queue (multi-GPU: possibly several at once).
+ count += self._cancel_in_progress_matching(match_filter, params)
return CancelByQueueIDResult(canceled=count)
+ def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult:
+ with self._db.transaction() as cursor:
+ # Build WHERE clause with optional user_id filter
+ user_filter = "AND user_id = ?" if user_id is not None else ""
+ where = f"""--sql
+ WHERE
+ queue_id == ?
+ AND status == 'pending'
+ {user_filter}
+ """
+ params = [queue_id]
+ if user_id is not None:
+ params.append(user_id)
+
+ cursor.execute(
+ f"""--sql
+ SELECT COUNT(*)
+ FROM session_queue
+ {where};
+ """,
+ tuple(params),
+ )
+ count = cursor.fetchone()[0]
+ cursor.execute(
+ f"""--sql
+ UPDATE session_queue
+ SET status = 'canceled',
+ status_sequence = COALESCE(status_sequence, 0) + 1
+ {where};
+ """,
+ tuple(params),
+ )
+ return CancelAllExceptCurrentResult(canceled=count)
+
def get_queue_item(self, item_id: int) -> SessionQueueItem:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- SELECT * FROM session_queue
- WHERE
- item_id = ?
+ SELECT
+ sq.*,
+ u.display_name as user_display_name,
+ u.email as user_email
+ FROM session_queue sq
+ LEFT JOIN users u ON sq.user_id = u.user_id
+ WHERE sq.item_id = ?
""",
(item_id,),
)
- result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone())
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ result = cast(Union[sqlite3.Row, None], cursor.fetchone())
if result is None:
raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}")
return SessionQueueItem.queue_item_from_dict(dict(result))
def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem:
- try:
+ with self._db.transaction() as cursor:
# Use exclude_none so we don't end up with a bunch of nulls in the graph - this can cause validation errors
# when the graph is loaded. Graph execution occurs purely in memory - the session saved here is not referenced
# during execution.
session_json = session.model_dump_json(warnings=False, exclude_none=True)
- self.__lock.acquire()
- self.__cursor.execute(
+ cursor.execute(
"""--sql
UPDATE session_queue
SET session = ?
@@ -508,12 +770,6 @@ def set_queue_item_session(self, item_id: int, session: GraphExecutionState) ->
""",
(session_json, item_id),
)
- self.__conn.commit()
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
return self.get_queue_item(item_id)
def list_queue_items(
@@ -523,25 +779,12 @@ def list_queue_items(
priority: int,
cursor: Optional[int] = None,
status: Optional[QUEUE_ITEM_STATUS] = None,
- ) -> CursorPaginatedResults[SessionQueueItemDTO]:
- try:
+ destination: Optional[str] = None,
+ ) -> CursorPaginatedResults[SessionQueueItem]:
+ with self._db.transaction() as cursor_:
item_id = cursor
- self.__lock.acquire()
query = """--sql
- SELECT item_id,
- status,
- priority,
- field_values,
- error_type,
- error_message,
- error_traceback,
- created_at,
- updated_at,
- completed_at,
- started_at,
- session_id,
- batch_id,
- queue_id
+ SELECT *
FROM session_queue
WHERE queue_id = ?
"""
@@ -553,6 +796,12 @@ def list_queue_items(
"""
params.append(status)
+ if destination is not None:
+ query += """---sql
+ AND destination = ?
+ """
+ params.append(destination)
+
if item_id is not None:
query += """--sql
AND (priority < ?) OR (priority = ? AND item_id > ?)
@@ -561,30 +810,93 @@ def list_queue_items(
query += """--sql
ORDER BY
- priority DESC,
- item_id ASC
+ priority DESC,
+ item_id ASC
LIMIT ?
"""
params.append(limit + 1)
- self.__cursor.execute(query, params)
- results = cast(list[sqlite3.Row], self.__cursor.fetchall())
- items = [SessionQueueItemDTO.queue_item_dto_from_dict(dict(result)) for result in results]
- has_more = False
- if len(items) > limit:
- # remove the extra item
- items.pop()
- has_more = True
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ cursor_.execute(query, params)
+ results = cast(list[sqlite3.Row], cursor_.fetchall())
+ items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results]
+ has_more = False
+ if len(items) > limit:
+ # remove the extra item
+ items.pop()
+ has_more = True
return CursorPaginatedResults(items=items, limit=limit, has_more=has_more)
- def get_queue_status(self, queue_id: str) -> SessionQueueStatus:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
+ def list_all_queue_items(
+ self,
+ queue_id: str,
+ destination: Optional[str] = None,
+ ) -> list[SessionQueueItem]:
+ """Gets all queue items that match the given parameters"""
+ with self._db.transaction() as cursor:
+ query = """--sql
+ SELECT
+ sq.*,
+ u.display_name as user_display_name,
+ u.email as user_email
+ FROM session_queue sq
+ LEFT JOIN users u ON sq.user_id = u.user_id
+ WHERE sq.queue_id = ?
+ """
+ params: list[Union[str, int]] = [queue_id]
+
+ if destination is not None:
+ query += """---sql
+ AND sq.destination = ?
+ """
+ params.append(destination)
+
+ query += """--sql
+ ORDER BY
+ sq.priority DESC,
+ sq.item_id ASC
+ ;
+ """
+ cursor.execute(query, params)
+ results = cast(list[sqlite3.Row], cursor.fetchall())
+ items = [SessionQueueItem.queue_item_from_dict(dict(result)) for result in results]
+ return items
+
+ def get_queue_item_ids(
+ self,
+ queue_id: str,
+ order_dir: SQLiteDirection = SQLiteDirection.Descending,
+ user_id: Optional[str] = None,
+ ) -> ItemIdsResult:
+ with self._db.transaction() as cursor_:
+ query = """--sql
+ SELECT item_id
+ FROM session_queue
+ WHERE queue_id = ?
+ """
+ query_params: list[str] = [queue_id]
+
+ if user_id is not None:
+ query += " AND user_id = ?"
+ query_params.append(user_id)
+
+ query += f" ORDER BY created_at {order_dir.value}"
+
+ cursor_.execute(query, query_params)
+ result = cast(list[sqlite3.Row], cursor_.fetchall())
+ item_ids = [row[0] for row in result]
+
+ return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids))
+
+ def get_queue_status(
+ self,
+ queue_id: str,
+ user_id: Optional[str] = None,
+ acting_user_id: Optional[str] = None,
+ ) -> SessionQueueStatus:
+ with self._db.transaction() as cursor:
+ # Aggregate counts are always global (across all users). This lets a non-admin's
+ # badge show "own / total" — their share of the whole queue — and lets the queue
+ # list surface (redacted) entries belonging to other users.
+ cursor.execute(
"""--sql
SELECT status, count(*)
FROM session_queue
@@ -593,54 +905,84 @@ def get_queue_status(self, queue_id: str) -> SessionQueueStatus:
""",
(queue_id,),
)
- counts_result = cast(list[sqlite3.Row], self.__cursor.fetchall())
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ counts_result = cast(list[sqlite3.Row], cursor.fetchall())
+
+ # When user_id is provided, additionally compute that user's own counts so the
+ # caller can render the per-user portion of the badge. These are returned in the
+ # separate user_pending/user_in_progress fields and never replace the global counts.
+ user_counts_result: list[sqlite3.Row] = []
+ if user_id is not None:
+ cursor.execute(
+ """--sql
+ SELECT status, count(*)
+ FROM session_queue
+ WHERE queue_id = ? AND user_id = ?
+ GROUP BY status
+ """,
+ (queue_id, user_id),
+ )
+ user_counts_result = cast(list[sqlite3.Row], cursor.fetchall())
current_item = self.get_current(queue_id=queue_id)
- total = sum(row[1] for row in counts_result)
+ total = sum(row[1] or 0 for row in counts_result)
counts: dict[str, int] = {row[0]: row[1] for row in counts_result}
+
+ user_pending: Optional[int] = None
+ user_in_progress: Optional[int] = None
+ if user_id is not None:
+ user_counts: dict[str, int] = {row[0]: row[1] for row in user_counts_result}
+ user_pending = user_counts.get("pending", 0)
+ user_in_progress = user_counts.get("in_progress", 0)
+
+ # Redaction is decided from the same current_item snapshot used to embed identifiers,
+ # so a concurrent transition (e.g. B finishing while A's status changes) cannot leave
+ # stale identifiers in the result. The aggregate counts stay global; only the current
+ # item's identifiers are gated. acting_user_id (event path) takes precedence over
+ # user_id (API path) when deciding the redaction owner; either being None means an
+ # admin/global caller who may see the current item.
+ owner_user_id = user_id if acting_user_id is None else acting_user_id
+ show_current_item = current_item is not None and (
+ owner_user_id is None or current_item.user_id == owner_user_id
+ )
+
return SessionQueueStatus(
queue_id=queue_id,
- item_id=current_item.item_id if current_item else None,
- session_id=current_item.session_id if current_item else None,
- batch_id=current_item.batch_id if current_item else None,
+ item_id=current_item.item_id if show_current_item else None,
+ session_id=current_item.session_id if show_current_item else None,
+ batch_id=current_item.batch_id if show_current_item else None,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),
completed=counts.get("completed", 0),
failed=counts.get("failed", 0),
canceled=counts.get("canceled", 0),
total=total,
+ user_pending=user_pending,
+ user_in_progress=user_in_progress,
)
- def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus:
- try:
- self.__lock.acquire()
- self.__cursor.execute(
- """--sql
- SELECT status, count(*)
+ def get_batch_status(self, queue_id: str, batch_id: str, user_id: Optional[str] = None) -> BatchStatus:
+ with self._db.transaction() as cursor:
+ query = """--sql
+ SELECT status, count(*), origin, destination
FROM session_queue
- WHERE
- queue_id = ?
- AND batch_id = ?
- GROUP BY status
- """,
- (queue_id, batch_id),
- )
- result = cast(list[sqlite3.Row], self.__cursor.fetchall())
- total = sum(row[1] for row in result)
- counts: dict[str, int] = {row[0]: row[1] for row in result}
- except Exception:
- self.__conn.rollback()
- raise
- finally:
- self.__lock.release()
+ WHERE queue_id = ? AND batch_id = ?
+ """
+ params: list[str] = [queue_id, batch_id]
+ if user_id is not None:
+ query += " AND user_id = ?"
+ params.append(user_id)
+ query += " GROUP BY status"
+ cursor.execute(query, params)
+ result = cast(list[sqlite3.Row], cursor.fetchall())
+ total = sum(row[1] or 0 for row in result)
+ counts: dict[str, int] = {row[0]: row[1] for row in result}
+ origin = result[0]["origin"] if result else None
+ destination = result[0]["destination"] if result else None
return BatchStatus(
batch_id=batch_id,
+ origin=origin,
+ destination=destination,
queue_id=queue_id,
pending=counts.get("pending", 0),
in_progress=counts.get("in_progress", 0),
@@ -649,3 +991,95 @@ def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus:
canceled=counts.get("canceled", 0),
total=total,
)
+
+ def get_counts_by_destination(
+ self, queue_id: str, destination: str, user_id: Optional[str] = None
+ ) -> SessionQueueCountsByDestination:
+ with self._db.transaction() as cursor:
+ query = """--sql
+ SELECT status, count(*)
+ FROM session_queue
+ WHERE queue_id = ? AND destination = ?
+ """
+ params: list[str] = [queue_id, destination]
+ if user_id is not None:
+ query += " AND user_id = ?"
+ params.append(user_id)
+ query += " GROUP BY status"
+ cursor.execute(query, params)
+ counts_result = cast(list[sqlite3.Row], cursor.fetchall())
+
+ total = sum(row[1] or 0 for row in counts_result)
+ counts: dict[str, int] = {row[0]: row[1] for row in counts_result}
+
+ return SessionQueueCountsByDestination(
+ queue_id=queue_id,
+ destination=destination,
+ pending=counts.get("pending", 0),
+ in_progress=counts.get("in_progress", 0),
+ completed=counts.get("completed", 0),
+ failed=counts.get("failed", 0),
+ canceled=counts.get("canceled", 0),
+ total=total,
+ )
+
+ def retry_items_by_id(self, queue_id: str, item_ids: list[int]) -> RetryItemsResult:
+ """Retries the given queue items"""
+ with self._db.transaction() as cursor:
+ values_to_insert: list[ValueToInsertTuple] = []
+ retried_item_ids: list[int] = []
+
+ for item_id in item_ids:
+ queue_item = self.get_queue_item(item_id)
+
+ if queue_item.status not in ("failed", "canceled"):
+ continue
+
+ retried_item_ids.append(item_id)
+
+ field_values_json = (
+ json.dumps(queue_item.field_values, default=to_jsonable_python) if queue_item.field_values else None
+ )
+ workflow_json = (
+ json.dumps(queue_item.workflow, default=to_jsonable_python) if queue_item.workflow else None
+ )
+ cloned_session = GraphExecutionState(graph=queue_item.session.graph)
+ cloned_session_json = cloned_session.model_dump_json(warnings=False, exclude_none=True)
+
+ retried_from_item_id = (
+ queue_item.retried_from_item_id
+ if queue_item.retried_from_item_id is not None
+ else queue_item.item_id
+ )
+
+ value_to_insert: ValueToInsertTuple = (
+ queue_item.queue_id,
+ cloned_session_json,
+ cloned_session.id,
+ queue_item.batch_id,
+ field_values_json,
+ queue_item.priority,
+ workflow_json,
+ queue_item.origin,
+ queue_item.destination,
+ retried_from_item_id,
+ queue_item.user_id,
+ )
+ values_to_insert.append(value_to_insert)
+
+ # TODO(psyche): Handle max queue size?
+
+ cursor.executemany(
+ """--sql
+ INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ values_to_insert,
+ )
+
+ retry_result = RetryItemsResult(
+ queue_id=queue_id,
+ retried_item_ids=retried_item_ids,
+ )
+ self.__invoker.services.events.emit_queue_items_retried(retry_result)
+ return retry_result
diff --git a/invokeai/app/services/shared/README.md b/invokeai/app/services/shared/README.md
new file mode 100644
index 00000000000..f92b1f1ea2e
--- /dev/null
+++ b/invokeai/app/services/shared/README.md
@@ -0,0 +1,257 @@
+# InvokeAI Graph - Design Overview
+
+High-level design for the graph module. Focuses on responsibilities, data flow, and how traversal works.
+
+## 1) Purpose
+
+Provide a typed, acyclic workflow model (**Graph**) plus a runtime scheduler (**GraphExecutionState**) that expands
+iterator patterns, tracks readiness via indegree (the number of incoming edges to a node in the directed graph), and
+executes nodes in class-grouped batches. In normal execution, runtime expansion happens in a separate execution graph
+instead of mutating the source graph.
+
+## 2) Major Data Types
+
+### EdgeConnection
+
+- Fields: `node_id: str`, `field: str`.
+- Hashable; printed as `node.field` for readable diagnostics.
+
+### Edge
+
+- Fields: `source: EdgeConnection`, `destination: EdgeConnection`.
+- One directed connection from a specific output port to a specific input port.
+
+### AnyInvocation / AnyInvocationOutput
+
+- Pydantic wrappers that carry concrete invocation models and outputs.
+- No registry logic in this file; they are permissive containers for heterogeneous nodes.
+
+### IterateInvocation / CollectInvocation
+
+- Control nodes used by validation and execution:
+
+ - **IterateInvocation**: input `collection`, outputs include `item` (and index/total).
+ - **CollectInvocation**: many `item` inputs aggregated to one `collection` output.
+
+## 3) Graph (author-time model)
+
+A container for declared nodes and edges. Does **not** perform iteration expansion.
+
+### 3.1 Data
+
+- `nodes: dict[str, AnyInvocation]` - key must equal `node.id`.
+- `edges: list[Edge]` - zero or more.
+- Utility: `_get_input_edges(node_id, field?)`, `_get_output_edges(node_id, field?)` These scan `self.edges` (no
+ adjacency indices in the current code).
+
+### 3.2 Validation (`validate_self`)
+
+Runs a sequence of checks:
+
+1. **Node ID uniqueness** No duplicate IDs; map key equals `node.id`.
+
+1. **Endpoint existence** Source and destination node IDs must exist.
+
+1. **Port existence** Input ports must exist on the node class; output ports on the node's output model.
+
+1. **DAG constraint** Build a *flat* `DiGraph` (no runtime expansion) and assert acyclicity.
+
+1. **Type compatibility** `get_output_field_type` vs `get_input_field_type` and `are_connection_types_compatible`.
+
+1. **Iterator / collector structure** Enforce special rules:
+
+ - Iterator's input must be `collection`; its outgoing edges use `item`.
+ - Collector accepts many `item` inputs; outputs a single `collection`.
+ - Edge fan-in to a non-collector input is rejected.
+
+### 3.3 Edge admission (`_validate_edge`)
+
+Checks a single prospective edge before insertion:
+
+- Endpoints/ports exist.
+- Destination port is not already occupied unless it's a collector `item`.
+- Adding the edge to the flat DAG must keep it acyclic.
+- Iterator/collector constraints re-checked when the edge creates relevant patterns.
+
+### 3.4 Topology utilities
+
+- `nx_graph()` - DiGraph of declared nodes and edges.
+- `nx_graph_flat()` - "flattened" DAG (still author-time; no runtime copies). Used in validation and in `_prepare()`
+ during execution planning.
+
+### 3.5 Mutation helpers
+
+- `add_node`, `update_node` (preserve edges, rewrite endpoints if id changes), `delete_node`.
+- `add_edge`, `delete_edge` (with validation).
+
+## 4) GraphExecutionState (runtime)
+
+Holds the state for a single run. Keeps the source graph intact and materializes a separate execution graph.
+`GraphExecutionState` is still the public runtime entry point, but most execution behavior is now delegated to a small
+set of internal helper classes.
+
+The source graph is treated as stable during normal execution, but the runtime object still exposes guarded graph
+mutation helpers. Those helpers reject changes once the affected nodes have already been prepared or executed.
+
+### 4.1 Data
+
+- `graph: Graph` - source graph for the run; treated as stable during normal execution.
+- `execution_graph: Graph` - materialized runtime nodes/edges. This is mutable runtime state, not an immutable audit
+ log. Lazy `If` pruning may remove unselected input edges during execution, so persisted failed/completed session
+ snapshots can contain a structurally pruned execution graph. Retry paths rebuild from `graph`, not from a previously
+ persisted `execution_graph`.
+- `executed: set[str]`, `executed_history: list[str]`.
+- `results: dict[str, AnyInvocationOutput]`, `errors: dict[str, str]`.
+- `prepared_source_mapping: dict[str, str]` - exec id -> source id.
+- `source_prepared_mapping: dict[str, set[str]]` - source id -> exec ids.
+- `indegree: dict[str, int]` - unmet inputs per exec node.
+- Prepared exec metadata caches:
+ - source node id
+ - iteration path
+ - runtime state such as pending, ready, executed, or skipped
+- **Ready queues grouped by class** (private attrs): `_ready_queues: dict[class_name, deque[str]]`,
+ `_active_class: Optional[str]`. Optional `ready_order: list[str]` to prioritize classes.
+
+### 4.2 Core methods
+
+- `next()` Returns the next ready exec node. If none are ready, it asks the materializer to expand more source nodes and
+ then retries. Before returning a node, the runtime helper deep-copies inbound values into the node fields.
+- `complete(node_id, output)` Records the result, marks the exec node executed, marks the source node executed once all
+ of its prepared exec copies are done, then decrements downstream indegrees and enqueues newly ready nodes.
+
+### 4.3 Runtime helper classes
+
+`GraphExecutionState` now delegates most runtime behavior to internal helpers:
+
+- `_PreparedExecRegistry` Owns the relationship between source graph nodes and prepared execution graph nodes, plus
+ cached metadata such as iteration path and runtime state.
+- `_ExecutionMaterializer` Expands source graph nodes into concrete execution graph nodes when the scheduler runs out of
+ ready work. When matching prepared parents for a downstream exec node, skipped prepared exec nodes are ignored and
+ cannot be selected as live inputs.
+- `_ExecutionScheduler` Owns indegree transitions, ready queues, class batching, and downstream release on completion.
+- `_ExecutionRuntime` Owns iteration-path lookup and input hydration for prepared exec nodes.
+- `_IfBranchScheduler` Applies lazy `If` semantics by deferring branch-local work until the condition is known, then
+ releasing the selected branch and skipping the unselected branch.
+
+### 4.4 Preparation (`_prepare()`)
+
+- Build a flat DAG from the **source** graph.
+
+- Choose the **next source node** in topological order that:
+
+ 1. has not been prepared,
+ 1. if it is an iterator, *its inputs are already executed*,
+ 1. it has *no unexecuted iterator ancestors*.
+
+- If the node is a **CollectInvocation**: collapse all prepared parents into one mapping and create **one** exec node.
+
+- Otherwise: compute all combinations of prepared iterator ancestors. For each combination, choose the prepared parent
+ for each upstream by matching iterator ancestry, then create **one** exec node.
+
+- For each new exec node:
+
+ - Deep-copy the source node; assign a fresh ID (and `index` for iterators).
+ - Wire edges from chosen prepared parents.
+ - Set `indegree = number of unmet inputs` (i.e., parents not yet executed).
+ - Try to resolve any `If`-specific scheduling state.
+ - If the node is ready and not deferred by an unresolved `If`, enqueue it into its class queue.
+
+### 4.5 Readiness and batching
+
+- `_enqueue_if_ready(nid)` enqueues by class name only when `indegree == 0`, the node has not already executed, and the
+ node is not deferred by an unresolved `If`.
+- `_get_next_node()` drains the `_active_class` queue FIFO; when empty, selects the next nonempty class queue (by
+ `ready_order` if set, else alphabetical), and continues. Optional fairness knobs can limit batch size per class;
+ default is drain fully.
+
+#### 4.5.1 Indegree (what it is and how it's used)
+
+**Indegree** is the number of incoming edges to a node in the execution graph that are still unmet. In this engine:
+
+- For every materialized exec node, `indegree[node]` equals the count of its prerequisite parents that have **not**
+ finished yet.
+- A node is "ready" exactly when `indegree[node] == 0`; only then is it enqueued.
+- When a node completes, the scheduler decrements `indegree[child]` for each outgoing edge. Any child that reaches 0 is
+ enqueued.
+
+Example: edges `A->C`, `B->C`, `C->D`. Start: `A:0, B:0, C:2, D:1`. Run `A` -> `C:1`. Run `B` -> `C:0` -> enqueue `C`.
+Run `C` -> `D:0` -> enqueue `D`. Run `D` -> done.
+
+### 4.6 Input hydration (`_prepare_inputs()`)
+
+- For **CollectInvocation**: gather all incoming `item` values into `collection`, sorting inputs by iteration path so
+ collected results are stable across expanded iterations. Incoming `collection` values are merged first, then incoming
+ `item` values are appended.
+- For **IfInvocation**: hydrate only `condition` and the selected branch input. As a defensive guard against
+ inconsistent runtime or deserialized session state, the runtime raises if the selected input edge points at an exec
+ node with no stored runtime output. In normal scheduling this path should be unreachable.
+- For all others: deep-copy each incoming edge's value into the destination field. This prevents cross-node mutation
+ through shared references.
+
+### 4.7 Lazy `If` semantics
+
+`IfInvocation` now acts as a lazy branch boundary rather than a simple value multiplexer.
+
+- The `condition` input must resolve first.
+- Nodes that are exclusive to the true or false branch can remain deferred even when their indegree is zero.
+- Once the prepared `If` node resolves its condition:
+ - the selected branch is released
+ - the unselected branch is marked skipped
+ - unselected input edges on the prepared `If` exec node are pruned from the execution graph so they no longer
+ participate in downstream indegree accounting
+ - branch-exclusive ancestors of the unselected branch are never executed
+- Skipped branch-local exec nodes may still be treated as executed for scheduling purposes, but they do not create
+ entries in `results`.
+- Shared ancestors still execute if they are required by the selected branch or by any other live path in the graph.
+
+This behavior is implemented in the runtime scheduler, not in the invocation body itself.
+
+## 5) Traversal Summary
+
+1. Author builds a valid **Graph**.
+
+1. Create **GraphExecutionState** with that graph.
+
+1. Loop:
+
+ - `node = state.next()` -> may trigger `_prepare()` expansion.
+ - Execute node externally -> `output`.
+ - `state.complete(node.id, output)` -> updates indegrees, `If` state, and ready queues.
+
+1. Finish when `next()` returns `None`.
+
+In normal execution, all runtime expansion occurs in `execution_graph` with traceability back to source nodes.
+
+## 6) Invariants
+
+- Source **Graph** remains a DAG and type-consistent.
+- `execution_graph` remains a DAG.
+- Nodes are enqueued only when `indegree == 0` and they are not deferred by an unresolved `If`.
+- `results` and `errors` are keyed by **exec node id**.
+- Collectors aggregate `item` inputs and may also merge incoming `collection` inputs during runtime hydration.
+- Branch-exclusive nodes behind an unselected `If` branch are skipped, not failed.
+
+## 7) Extensibility
+
+- **New node types**: implement as Pydantic models with typed fields and outputs. Register per your invocation system;
+ this file accepts them as `AnyInvocation`.
+- **Scheduling policy**: adjust `ready_order` to batch by class; add a batch cap for fairness without changing
+ complexity.
+- **Dynamic behaviors** (future): can be added in `GraphExecutionState` by creating exec nodes and edges at `complete()`
+ time, as long as the DAG invariant holds.
+
+## 8) Error Model (selected)
+
+- `DuplicateNodeIdError`, `NodeAlreadyInGraphError`
+- `NodeNotFoundError`, `NodeFieldNotFoundError`
+- `InvalidEdgeError`, `CyclicalGraphError`
+- `NodeInputError` (raised when preparing inputs for execution)
+
+Messages favor short, precise diagnostics (node id, field, and failing condition).
+
+## 9) Rationale
+
+- **Two-graph approach** isolates authoring from execution expansion and keeps validation simple.
+- **Indegree + queues** gives O(1) scheduling decisions with clear batching semantics.
+- **Iterator/collector separation** keeps fan-out/fan-in explicit and testable.
+- **Deep-copy hydration** avoids incidental aliasing bugs between nodes.
diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py
index d745e738233..aa47c3b4bb5 100644
--- a/invokeai/app/services/shared/graph.py
+++ b/invokeai/app/services/shared/graph.py
@@ -2,13 +2,17 @@
import copy
import itertools
-from typing import Any, Optional, TypeVar, Union, get_args, get_origin, get_type_hints
+from collections import deque
+from dataclasses import dataclass
+from typing import Any, Deque, Iterable, Literal, Optional, Type, TypeVar, Union, get_args, get_origin
import networkx as nx
from pydantic import (
BaseModel,
+ ConfigDict,
GetCoreSchemaHandler,
GetJsonSchemaHandler,
+ PrivateAttr,
ValidationError,
field_validator,
)
@@ -21,16 +25,22 @@
from invokeai.app.invocations.baseinvocation import (
BaseInvocation,
BaseInvocationOutput,
+ InvocationRegistry,
invocation,
invocation_output,
)
from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType
+from invokeai.app.invocations.logic import IfInvocation
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.app.util.misc import uuid_string
# in 3.10 this would be "from types import NoneType"
NoneType = type(None)
+# Port name constants
+ITEM_FIELD = "item"
+COLLECTION_FIELD = "collection"
+
class EdgeConnection(BaseModel):
node_id: str = Field(description="The id of the node for this edge connection")
@@ -51,19 +61,784 @@ class Edge(BaseModel):
source: EdgeConnection = Field(description="The connection for the edge's from node and field")
destination: EdgeConnection = Field(description="The connection for the edge's to node and field")
+ def __str__(self):
+ return f"{self.source.node_id}.{self.source.field} -> {self.destination.node_id}.{self.destination.field}"
+
+
+PreparedExecState = Literal["pending", "ready", "executed", "skipped"]
+
+
+@dataclass
+class _PreparedExecNodeMetadata:
+ """Cached metadata for a materialized execution node."""
+
+ source_node_id: str
+ iteration_path: Optional[tuple[int, ...]] = None
+ state: PreparedExecState = "pending"
+
+
+class _PreparedExecRegistry:
+ """Tracks prepared execution nodes and their relationship to source graph nodes."""
+
+ def __init__(
+ self,
+ prepared_source_mapping: dict[str, str],
+ source_prepared_mapping: dict[str, set[str]],
+ metadata: dict[str, _PreparedExecNodeMetadata],
+ ) -> None:
+ self._prepared_source_mapping = prepared_source_mapping
+ self._source_prepared_mapping = source_prepared_mapping
+ self._metadata = metadata
+
+ def register(self, exec_node_id: str, source_node_id: str) -> None:
+ self._prepared_source_mapping[exec_node_id] = source_node_id
+ self._metadata[exec_node_id] = _PreparedExecNodeMetadata(source_node_id=source_node_id)
+ if source_node_id not in self._source_prepared_mapping:
+ self._source_prepared_mapping[source_node_id] = set()
+ self._source_prepared_mapping[source_node_id].add(exec_node_id)
+
+ def get_metadata(self, exec_node_id: str) -> _PreparedExecNodeMetadata:
+ metadata = self._metadata.get(exec_node_id)
+ if metadata is None:
+ metadata = _PreparedExecNodeMetadata(source_node_id=self._prepared_source_mapping[exec_node_id])
+ self._metadata[exec_node_id] = metadata
+ return metadata
+
+ def get_source_node_id(self, exec_node_id: str) -> str:
+ metadata = self._metadata.get(exec_node_id)
+ if metadata is not None:
+ return metadata.source_node_id
+ return self._prepared_source_mapping[exec_node_id]
+
+ def get_prepared_ids(self, source_node_id: str) -> set[str]:
+ return self._source_prepared_mapping.get(source_node_id, set())
+
+ def set_state(self, exec_node_id: str, state: PreparedExecState) -> None:
+ self.get_metadata(exec_node_id).state = state
+
+ def get_iteration_path(self, exec_node_id: str) -> Optional[tuple[int, ...]]:
+ metadata = self._metadata.get(exec_node_id)
+ return metadata.iteration_path if metadata is not None else None
+
+ def set_iteration_path(self, exec_node_id: str, iteration_path: tuple[int, ...]) -> None:
+ self.get_metadata(exec_node_id).iteration_path = iteration_path
+
+
+class _IfBranchScheduler:
+ """Applies lazy `If` semantics by deferring, releasing, and skipping branch-local exec nodes."""
+
+ def __init__(self, state: "GraphExecutionState") -> None:
+ self._state = state
+
+ def _get_branch_input_sources(self, if_node_id: str, branch_field: str) -> set[str]:
+ return {e.source.node_id for e in self._state.graph._get_input_edges(if_node_id, branch_field)}
+
+ def _expand_with_ancestors(self, node_ids: set[str]) -> set[str]:
+ expanded = set(node_ids)
+ source_graph = self._state.graph.nx_graph_flat()
+ for node_id in list(expanded):
+ expanded.update(nx.ancestors(source_graph, node_id))
+ return expanded
+
+ def _node_outputs_stay_in_branch(
+ self, node_id: str, if_node_id: str, branch_field: str, branch_nodes: set[str]
+ ) -> bool:
+ output_edges = self._state.graph._get_output_edges(node_id)
+ return all(
+ edge.destination.node_id in branch_nodes
+ or (edge.destination.node_id == if_node_id and edge.destination.field == branch_field)
+ for edge in output_edges
+ )
+
+ def _prune_nonexclusive_branch_nodes(
+ self, if_node_id: str, branch_field: str, candidate_nodes: set[str]
+ ) -> set[str]:
+ exclusive_nodes = set(candidate_nodes)
+ changed = True
+ while changed:
+ changed = False
+ for node_id in list(exclusive_nodes):
+ if self._node_outputs_stay_in_branch(node_id, if_node_id, branch_field, exclusive_nodes):
+ continue
+ exclusive_nodes.remove(node_id)
+ changed = True
+ return exclusive_nodes
+
+ def _get_matching_prepared_if_ids(self, if_node_id: str, iteration_path: tuple[int, ...]) -> list[str]:
+ prepared_if_ids = self._state._prepared_registry().get_prepared_ids(if_node_id)
+ return [pid for pid in prepared_if_ids if self._state._get_iteration_path(pid) == iteration_path]
+
+ def _has_unresolved_matching_if(self, if_node_id: str, iteration_path: tuple[int, ...]) -> bool:
+ matching_prepared_if_ids = self._get_matching_prepared_if_ids(if_node_id, iteration_path)
+ if not matching_prepared_if_ids:
+ return True
+ return not all(pid in self._state._resolved_if_exec_branches for pid in matching_prepared_if_ids)
+
+ def _apply_condition_inputs(self, exec_node_id: str, node: IfInvocation) -> bool:
+ return self._state._apply_if_condition_inputs(exec_node_id, node)
+
+ def _get_selected_branch_fields(self, node: IfInvocation) -> tuple[str, str]:
+ selected_field = "true_input" if node.condition else "false_input"
+ unselected_field = "false_input" if node.condition else "true_input"
+ return selected_field, unselected_field
+
+ def _prune_unselected_if_inputs(self, exec_node_id: str, unselected_field: str) -> None:
+ for edge in self._state.execution_graph._get_input_edges(exec_node_id, unselected_field):
+ if edge.source.node_id not in self._state.executed:
+ if self._state.indegree[exec_node_id] == 0:
+ raise RuntimeError(f"indegree underflow for {exec_node_id} when pruning {unselected_field}")
+ self._state.indegree[exec_node_id] -= 1
+ self._state.execution_graph.delete_edge(edge)
+
+ def _apply_branch_resolution(
+ self,
+ exec_node_id: str,
+ iteration_path: tuple[int, ...],
+ exclusive_sources: dict[str, set[str]],
+ selected_field: str,
+ unselected_field: str,
+ ) -> None:
+ # This iterates over the stable prepared-source mapping while mutating per-exec runtime state such as ready
+ # queues, execution state, and prepared metadata. Branch resolution never adds or removes prepared exec nodes.
+ for prepared_id, prepared_source in self._state.prepared_source_mapping.items():
+ if prepared_id in self._state.executed:
+ continue
+ if self._state._get_iteration_path(prepared_id) != iteration_path:
+ continue
+ if prepared_source in exclusive_sources[selected_field]:
+ self._state._enqueue_if_ready(prepared_id)
+ elif prepared_source in exclusive_sources[unselected_field]:
+ self.mark_exec_node_skipped(prepared_id)
+
+ def get_branch_exclusive_sources(self, if_node_id: str) -> dict[str, set[str]]:
+ cached = self._state._if_branch_exclusive_sources.get(if_node_id)
+ if cached is not None:
+ return cached
+
+ branch_sources: dict[str, set[str]] = {}
+ for branch_field in ("true_input", "false_input"):
+ direct_inputs = self._get_branch_input_sources(if_node_id, branch_field)
+ candidate_nodes = self._expand_with_ancestors(direct_inputs)
+ branch_sources[branch_field] = self._prune_nonexclusive_branch_nodes(
+ if_node_id, branch_field, candidate_nodes
+ )
+
+ self._state._if_branch_exclusive_sources[if_node_id] = branch_sources
+ return branch_sources
+
+ def is_deferred_by_unresolved_if(self, exec_node_id: str) -> bool:
+ source_node_id = self._state._prepared_registry().get_source_node_id(exec_node_id)
+ iteration_path = self._state._get_iteration_path(exec_node_id)
+
+ for source_if_id, source_if_node in self._state.graph.nodes.items():
+ if not isinstance(source_if_node, IfInvocation):
+ continue
+
+ branches = self.get_branch_exclusive_sources(source_if_id)
+ if source_node_id not in branches["true_input"] and source_node_id not in branches["false_input"]:
+ continue
+
+ if self._has_unresolved_matching_if(source_if_id, iteration_path):
+ return True
+ return False
+
+ def mark_exec_node_skipped(self, exec_node_id: str) -> None:
+ state = self._state._get_prepared_exec_metadata(exec_node_id).state
+ if state in ("executed", "skipped"):
+ return
+
+ self._state._remove_from_ready_queues(exec_node_id)
+ self._state._set_prepared_exec_state(exec_node_id, "skipped")
+ self._state.executed.add(exec_node_id)
+
+ registry = self._state._prepared_registry()
+ source_node_id = registry.get_source_node_id(exec_node_id)
+ prepared_nodes = registry.get_prepared_ids(source_node_id)
+ if all(n in self._state.executed for n in prepared_nodes):
+ if source_node_id not in self._state.executed:
+ self._state.executed.add(source_node_id)
+ self._state.executed_history.append(source_node_id)
+
+ def try_resolve_if_node(self, exec_node_id: str) -> None:
+ if exec_node_id in self._state._resolved_if_exec_branches:
+ return
+ node = self._state.execution_graph.get_node(exec_node_id)
+ if not isinstance(node, IfInvocation):
+ return
+
+ if not self._apply_condition_inputs(exec_node_id, node):
+ return
+
+ selected_field, unselected_field = self._get_selected_branch_fields(node)
+ self._state._resolved_if_exec_branches[exec_node_id] = selected_field
+
+ source_if_node_id = self._state._prepared_registry().get_source_node_id(exec_node_id)
+ exclusive_sources = self.get_branch_exclusive_sources(source_if_node_id)
+
+ iteration_path = self._state._get_iteration_path(exec_node_id)
+ self._prune_unselected_if_inputs(exec_node_id, unselected_field)
+ self._apply_branch_resolution(exec_node_id, iteration_path, exclusive_sources, selected_field, unselected_field)
+ self._state._enqueue_if_ready(exec_node_id)
+
+
+class _ExecutionMaterializer:
+ """Expands source-graph nodes into concrete execution-graph nodes for the current runtime state.
+
+ `GraphExecutionState.next()` calls into this helper when no prepared exec node is ready. The materializer chooses
+ the next source node that can be expanded, creates the corresponding exec nodes in the execution graph, wires their
+ inputs, and initializes their scheduler state.
+ """
+
+ def __init__(self, state: "GraphExecutionState") -> None:
+ self._state = state
+
+ def _get_iterator_iteration_count(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> int:
+ input_collection_edge = next(iter(self._state.graph._get_input_edges(node_id, COLLECTION_FIELD)))
+ input_collection_prepared_node_id = next(
+ prepared_id
+ for source_id, prepared_id in iteration_node_map
+ if source_id == input_collection_edge.source.node_id
+ )
+ input_collection_output = self._state.results[input_collection_prepared_node_id]
+ input_collection = getattr(input_collection_output, input_collection_edge.source.field)
+ return len(input_collection)
+
+ def _get_new_node_iterations(
+ self, node: BaseInvocation, node_id: str, iteration_node_map: list[tuple[str, str]]
+ ) -> list[int]:
+ if not isinstance(node, IterateInvocation):
+ return [-1]
+
+ iteration_count = self._get_iterator_iteration_count(node_id, iteration_node_map)
+ if iteration_count == 0:
+ return []
+ return list(range(iteration_count))
+
+ def _build_execution_edges(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[Edge]:
+ input_edges = self._state.graph._get_input_edges(node_id)
+ new_edges: list[Edge] = []
+ for edge in input_edges:
+ matching_inputs = [
+ prepared_id for source_id, prepared_id in iteration_node_map if source_id == edge.source.node_id
+ ]
+ for input_node_id in matching_inputs:
+ new_edges.append(
+ Edge(
+ source=EdgeConnection(node_id=input_node_id, field=edge.source.field),
+ destination=EdgeConnection(node_id="", field=edge.destination.field),
+ )
+ )
+ return new_edges
+
+ def _create_execution_node_copy(self, node: BaseInvocation, node_id: str, iteration_index: int) -> BaseInvocation:
+ new_node = node.model_copy(deep=True)
+ new_node.id = uuid_string()
+
+ if isinstance(new_node, IterateInvocation):
+ new_node.index = iteration_index
+
+ self._state.execution_graph.add_node(new_node)
+ self._state._register_prepared_exec_node(new_node.id, node_id)
+ return new_node
+
+ def _attach_execution_edges(self, exec_node_id: str, new_edges: list[Edge]) -> None:
+ for edge in new_edges:
+ self._state.execution_graph.add_edge(
+ Edge(
+ source=edge.source,
+ destination=EdgeConnection(node_id=exec_node_id, field=edge.destination.field),
+ )
+ )
+
+ def _initialize_execution_node(self, exec_node_id: str) -> None:
+ inputs = self._state.execution_graph._get_input_edges(exec_node_id)
+ unmet = sum(1 for edge in inputs if edge.source.node_id not in self._state.executed)
+ self._state.indegree[exec_node_id] = unmet
+ self._state._try_resolve_if_node(exec_node_id)
+ self._state._enqueue_if_ready(exec_node_id)
+
+ def _get_collect_iteration_mappings(self, parent_node_ids: list[str]) -> list[tuple[str, str]]:
+ all_iteration_mappings: list[tuple[str, str]] = []
+ for source_node_id in parent_node_ids:
+ prepared_nodes = self._get_prepared_nodes_for_source(source_node_id)
+ all_iteration_mappings.extend((source_node_id, prepared_id) for prepared_id in prepared_nodes)
+ return all_iteration_mappings
+
+ def _get_parent_iteration_mappings(self, next_node_id: str, graph: nx.DiGraph) -> list[list[tuple[str, str]]]:
+ parent_node_ids = [source_id for source_id, _ in graph.in_edges(next_node_id)]
+ iterator_graph = self.iterator_graph(graph)
+ iterator_nodes = self.get_node_iterators(next_node_id, iterator_graph)
+ iterator_nodes_prepared = [list(self._state.source_prepared_mapping[node_id]) for node_id in iterator_nodes]
+ iterator_node_prepared_combinations = list(itertools.product(*iterator_nodes_prepared))
+
+ execution_graph = self._state.execution_graph.nx_graph_flat()
+ prepared_parent_mappings = [
+ [
+ (node_id, self.get_iteration_node(node_id, graph, execution_graph, prepared_iterators))
+ for node_id in parent_node_ids
+ ]
+ for prepared_iterators in iterator_node_prepared_combinations
+ ]
+ return [
+ mapping
+ for mapping in prepared_parent_mappings
+ if all(prepared_id is not None for _, prepared_id in mapping)
+ ]
+
+ def create_execution_node(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[str]:
+ """Prepares an iteration node and connects all edges, returning the new node id"""
+
+ node = self._state.graph.get_node(node_id)
+ iteration_indexes = self._get_new_node_iterations(node, node_id, iteration_node_map)
+ if not iteration_indexes:
+ return []
+
+ new_edges = self._build_execution_edges(node_id, iteration_node_map)
+ new_nodes: list[str] = []
+ for iteration_index in iteration_indexes:
+ new_node = self._create_execution_node_copy(node, node_id, iteration_index)
+ self._attach_execution_edges(new_node.id, new_edges)
+ self._initialize_execution_node(new_node.id)
+ new_nodes.append(new_node.id)
+
+ return new_nodes
+
+ def iterator_graph(self, base: Optional[nx.DiGraph] = None) -> nx.DiGraph:
+ """Gets a DiGraph with edges to collectors removed so an ancestor search produces all active iterators for any node"""
+ g = base.copy() if base is not None else self._state.graph.nx_graph_flat()
+ collectors = (
+ n for n in self._state.graph.nodes if isinstance(self._state.graph.get_node(n), CollectInvocation)
+ )
+ for c in collectors:
+ g.remove_edges_from(list(g.in_edges(c)))
+ return g
+
+ def get_node_iterators(self, node_id: str, it_graph: Optional[nx.DiGraph] = None) -> list[str]:
+ g = it_graph or self.iterator_graph()
+ return [n for n in nx.ancestors(g, node_id) if isinstance(self._state.graph.get_node(n), IterateInvocation)]
+
+ def _get_prepared_nodes_for_source(self, source_node_id: str) -> set[str]:
+ return {
+ exec_node_id
+ for exec_node_id in self._state.source_prepared_mapping[source_node_id]
+ if self._state._get_prepared_exec_metadata(exec_node_id).state != "skipped"
+ }
+
+ def _get_parent_iterator_exec_nodes(
+ self, source_node_id: str, graph: nx.DiGraph, prepared_iterator_nodes: list[str]
+ ) -> list[tuple[str, str]]:
+ iterator_source_node_mapping = [
+ (prepared_exec_node_id, self._state.prepared_source_mapping[prepared_exec_node_id])
+ for prepared_exec_node_id in prepared_iterator_nodes
+ ]
+ return [
+ iterator_mapping
+ for iterator_mapping in iterator_source_node_mapping
+ if nx.has_path(graph, iterator_mapping[1], source_node_id)
+ ]
+
+ def _matches_parent_iterators(
+ self, candidate_exec_node_id: str, parent_iterators: list[tuple[str, str]], execution_graph: nx.DiGraph
+ ) -> bool:
+ return all(
+ nx.has_path(execution_graph, parent_iterator_exec_id, candidate_exec_node_id)
+ for parent_iterator_exec_id, _ in parent_iterators
+ )
+
+ def _get_direct_prepared_iterator_match(
+ self,
+ prepared_nodes: set[str],
+ prepared_iterator_nodes: list[str],
+ parent_iterators: list[tuple[str, str]],
+ execution_graph: nx.DiGraph,
+ ) -> Optional[str]:
+ prepared_iterator = next((node_id for node_id in prepared_nodes if node_id in prepared_iterator_nodes), None)
+ if prepared_iterator is None:
+ return None
+ if self._matches_parent_iterators(prepared_iterator, parent_iterators, execution_graph):
+ return prepared_iterator
+ return None
+
+ def _find_prepared_node_matching_iterators(
+ self, prepared_nodes: set[str], parent_iterators: list[tuple[str, str]], execution_graph: nx.DiGraph
+ ) -> Optional[str]:
+ return next(
+ (
+ node_id
+ for node_id in prepared_nodes
+ if self._matches_parent_iterators(node_id, parent_iterators, execution_graph)
+ ),
+ None,
+ )
+
+ def get_iteration_node(
+ self,
+ source_node_id: str,
+ graph: nx.DiGraph,
+ execution_graph: nx.DiGraph,
+ prepared_iterator_nodes: list[str],
+ ) -> Optional[str]:
+ prepared_nodes = self._get_prepared_nodes_for_source(source_node_id)
+ if len(prepared_nodes) == 1 and not prepared_iterator_nodes:
+ return next(iter(prepared_nodes))
+
+ parent_iterators = self._get_parent_iterator_exec_nodes(source_node_id, graph, prepared_iterator_nodes)
+ if len(prepared_nodes) == 1:
+ prepared_node_id = next(iter(prepared_nodes))
+ if self._matches_parent_iterators(prepared_node_id, parent_iterators, execution_graph):
+ return prepared_node_id
+ return None
+
+ direct_iterator_match = self._get_direct_prepared_iterator_match(
+ prepared_nodes, prepared_iterator_nodes, parent_iterators, execution_graph
+ )
+ if direct_iterator_match is not None:
+ return direct_iterator_match
+
+ return self._find_prepared_node_matching_iterators(prepared_nodes, parent_iterators, execution_graph)
+
+ def prepare(self, base_g: Optional[nx.DiGraph] = None) -> Optional[str]:
+ g = base_g or self._state.graph.nx_graph_flat()
+ next_node_id = next(
+ (
+ node_id
+ for node_id in nx.topological_sort(g)
+ if node_id not in self._state.source_prepared_mapping
+ and (
+ not isinstance(self._state.graph.get_node(node_id), IterateInvocation)
+ or all(source_id in self._state.executed for source_id, _ in g.in_edges(node_id))
+ )
+ and not any(
+ isinstance(self._state.graph.get_node(ancestor_id), IterateInvocation)
+ and ancestor_id not in self._state.executed
+ for ancestor_id in nx.ancestors(g, node_id)
+ )
+ ),
+ None,
+ )
+
+ if next_node_id is None:
+ return None
+
+ next_node = self._state.graph.get_node(next_node_id)
+ new_node_ids: list[str] = []
+
+ if isinstance(next_node, CollectInvocation):
+ next_node_parents = [source_id for source_id, _ in g.in_edges(next_node_id)]
+ create_results = self.create_execution_node(
+ next_node_id, self._get_collect_iteration_mappings(next_node_parents)
+ )
+ if create_results is not None:
+ new_node_ids.extend(create_results)
+ else:
+ for iteration_mappings in self._get_parent_iteration_mappings(next_node_id, g):
+ create_results = self.create_execution_node(next_node_id, iteration_mappings)
+ if create_results is not None:
+ new_node_ids.extend(create_results)
+
+ return next(iter(new_node_ids), None)
+
+
+class _ExecutionScheduler:
+ """Owns ready-queue ordering and indegree-driven execution transitions."""
-def get_output_field(node: BaseInvocation, field: str) -> Any:
- node_type = type(node)
- node_outputs = get_type_hints(node_type.get_output_annotation())
- node_output_field = node_outputs.get(field) or None
- return node_output_field
+ def __init__(self, state: "GraphExecutionState") -> None:
+ self._state = state
+ def _validate_exec_node_ready_state(self, exec_node_id: str) -> None:
+ if exec_node_id not in self._state.execution_graph.nodes:
+ raise KeyError(f"exec node {exec_node_id} missing from execution_graph")
+ if exec_node_id not in self._state.indegree:
+ raise KeyError(f"indegree missing for exec node {exec_node_id}")
-def get_input_field(node: BaseInvocation, field: str) -> Any:
- node_type = type(node)
- node_inputs = get_type_hints(node_type)
- node_input_field = node_inputs.get(field) or None
- return node_input_field
+ def _should_skip_ready_enqueue(self, exec_node_id: str) -> bool:
+ return (
+ self._state.indegree[exec_node_id] != 0
+ or exec_node_id in self._state.executed
+ or self._state._is_deferred_by_unresolved_if(exec_node_id)
+ )
+
+ def _get_ready_queue(self, exec_node_id: str) -> Deque[str]:
+ node_obj = self._state.execution_graph.nodes[exec_node_id]
+ return self.queue_for(self._state._type_key(node_obj))
+
+ def _insert_ready_node(self, queue: Deque[str], exec_node_id: str) -> None:
+ exec_node_path = self._state._get_iteration_path(exec_node_id)
+ for i, existing in enumerate(queue):
+ if self._state._get_iteration_path(existing) > exec_node_path:
+ queue.insert(i, exec_node_id)
+ return
+ queue.append(exec_node_id)
+
+ def _record_completed_node(self, exec_node_id: str, output: BaseInvocationOutput) -> None:
+ self._state._set_prepared_exec_state(exec_node_id, "executed")
+ self._state.executed.add(exec_node_id)
+ self._state.results[exec_node_id] = output
+
+ def _mark_source_node_complete(self, exec_node_id: str) -> None:
+ registry = self._state._prepared_registry()
+ source_node_id = registry.get_source_node_id(exec_node_id)
+ prepared_nodes = registry.get_prepared_ids(source_node_id)
+ if all(node_id in self._state.executed for node_id in prepared_nodes):
+ self._state.executed.add(source_node_id)
+ self._state.executed_history.append(source_node_id)
+
+ def _decrement_child_indegree(self, child_exec_node_id: str, parent_exec_node_id: str) -> None:
+ if child_exec_node_id not in self._state.indegree:
+ raise KeyError(f"indegree missing for exec node {child_exec_node_id}")
+ if self._state.indegree[child_exec_node_id] == 0:
+ raise RuntimeError(f"indegree underflow for {child_exec_node_id} from parent {parent_exec_node_id}")
+ self._state.indegree[child_exec_node_id] -= 1
+
+ def _release_downstream_nodes(self, exec_node_id: str) -> None:
+ for edge in self._state.execution_graph._get_output_edges(exec_node_id):
+ child = edge.destination.node_id
+ self._decrement_child_indegree(child, exec_node_id)
+ self._state._try_resolve_if_node(child)
+ if self._state.indegree[child] == 0:
+ self.enqueue_if_ready(child)
+
+ def queue_for(self, cls_name: str) -> Deque[str]:
+ q = self._state._ready_queues.get(cls_name)
+ if q is None:
+ q = deque()
+ self._state._ready_queues[cls_name] = q
+ return q
+
+ def remove_from_ready_queues(self, exec_node_id: str) -> None:
+ for q in self._state._ready_queues.values():
+ try:
+ q.remove(exec_node_id)
+ except ValueError:
+ continue
+
+ def enqueue_if_ready(self, exec_node_id: str) -> None:
+ """Push exec_node_id to its class queue if unmet inputs == 0."""
+ self._validate_exec_node_ready_state(exec_node_id)
+ if self._should_skip_ready_enqueue(exec_node_id):
+ return
+ queue = self._get_ready_queue(exec_node_id)
+ if exec_node_id in queue:
+ return
+ self._state._set_prepared_exec_state(exec_node_id, "ready")
+ self._insert_ready_node(queue, exec_node_id)
+
+ def get_next_node(self) -> Optional[BaseInvocation]:
+ """Gets the next ready node: FIFO within class, drain class before switching."""
+ while True:
+ if self._state._active_class:
+ q = self._state._ready_queues.get(self._state._active_class)
+ while q:
+ exec_node_id = q.popleft()
+ if exec_node_id not in self._state.executed:
+ return self._state.execution_graph.nodes[exec_node_id]
+ self._state._active_class = None
+ continue
+
+ seen = set(self._state.ready_order)
+ next_class = next(
+ (cls_name for cls_name in self._state.ready_order if self._state._ready_queues.get(cls_name)),
+ None,
+ )
+ if next_class is None:
+ next_class = next(
+ (
+ cls_name
+ for cls_name in sorted(k for k in self._state._ready_queues.keys() if k not in seen)
+ if self._state._ready_queues[cls_name]
+ ),
+ None,
+ )
+ if next_class is None:
+ return None
+
+ self._state._active_class = next_class
+
+ def complete(self, exec_node_id: str, output: BaseInvocationOutput) -> None:
+ if exec_node_id not in self._state.execution_graph.nodes:
+ return
+
+ self._record_completed_node(exec_node_id, output)
+ self._mark_source_node_complete(exec_node_id)
+ self._release_downstream_nodes(exec_node_id)
+
+
+class _ExecutionRuntime:
+ """Provides runtime-only helpers such as iteration-path lookup and input hydration."""
+
+ def __init__(self, state: "GraphExecutionState") -> None:
+ self._state = state
+
+ def _get_cached_iteration_path(self, exec_node_id: str) -> Optional[tuple[int, ...]]:
+ registry = self._state._prepared_registry()
+ metadata_iteration_path = registry.get_iteration_path(exec_node_id)
+ if metadata_iteration_path is not None:
+ return metadata_iteration_path
+
+ return self._state._iteration_path_cache.get(exec_node_id)
+
+ def _get_iteration_source_node_id(self, exec_node_id: str) -> Optional[str]:
+ if exec_node_id not in self._state.prepared_source_mapping:
+ return None
+ return self._state._prepared_registry().get_source_node_id(exec_node_id)
+
+ def _get_ordered_iterator_sources(self, source_node_id: str) -> list[str]:
+ iterator_graph = self._state._iterator_graph(self._state.graph.nx_graph())
+ iterator_sources = [
+ node_id
+ for node_id in nx.ancestors(iterator_graph, source_node_id)
+ if isinstance(self._state.graph.get_node(node_id), IterateInvocation)
+ ]
+
+ topo = list(nx.topological_sort(iterator_graph))
+ topo_index = {node_id: i for i, node_id in enumerate(topo)}
+ iterator_sources.sort(key=lambda node_id: topo_index.get(node_id, 0))
+ return iterator_sources
+
+ def _get_iterator_exec_id(
+ self, iterator_source_id: str, exec_node_id: str, execution_graph: nx.DiGraph
+ ) -> Optional[str]:
+ prepared = self._state.source_prepared_mapping.get(iterator_source_id)
+ if not prepared:
+ return None
+ return next((pid for pid in prepared if nx.has_path(execution_graph, pid, exec_node_id)), None)
+
+ def _build_iteration_path(self, exec_node_id: str, source_node_id: str) -> tuple[int, ...]:
+ iterator_sources = self._get_ordered_iterator_sources(source_node_id)
+ execution_graph = self._state.execution_graph.nx_graph()
+ path: list[int] = []
+ for iterator_source_id in iterator_sources:
+ iterator_exec_id = self._get_iterator_exec_id(iterator_source_id, exec_node_id, execution_graph)
+ if iterator_exec_id is None:
+ continue
+ iterator_node = self._state.execution_graph.nodes.get(iterator_exec_id)
+ if isinstance(iterator_node, IterateInvocation):
+ path.append(iterator_node.index)
+
+ node_obj = self._state.execution_graph.nodes.get(exec_node_id)
+ if isinstance(node_obj, IterateInvocation):
+ path.append(node_obj.index)
+
+ return tuple(path)
+
+ def _cache_iteration_path(self, exec_node_id: str, iteration_path: tuple[int, ...]) -> tuple[int, ...]:
+ self._state._iteration_path_cache[exec_node_id] = iteration_path
+ self._state._prepared_registry().set_iteration_path(exec_node_id, iteration_path)
+ return iteration_path
+
+ def get_iteration_path(self, exec_node_id: str) -> tuple[int, ...]:
+ """Best-effort outer->inner iteration indices for an execution node, stopping at collectors."""
+ cached = self._get_cached_iteration_path(exec_node_id)
+ if cached is not None:
+ return cached
+
+ source_node_id = self._get_iteration_source_node_id(exec_node_id)
+ if source_node_id is None:
+ return self._cache_iteration_path(exec_node_id, ())
+
+ return self._cache_iteration_path(exec_node_id, self._build_iteration_path(exec_node_id, source_node_id))
+
+ def _sort_collect_input_edges(self, input_edges: list[Edge], field_name: str) -> list[Edge]:
+ matching_edges = [edge for edge in input_edges if edge.destination.field == field_name]
+ matching_edges.sort(key=lambda edge: (self.get_iteration_path(edge.source.node_id), edge.source.node_id))
+ return matching_edges
+
+ def _get_copied_result_value(self, edge: Edge) -> Any:
+ return copydeep(getattr(self._state.results[edge.source.node_id], edge.source.field))
+
+ def _try_get_copied_result_value(self, edge: Edge) -> tuple[bool, Any]:
+ source_output = self._state.results.get(edge.source.node_id)
+ if source_output is None:
+ return False, None
+ return True, copydeep(getattr(source_output, edge.source.field))
+
+ def _build_collect_collection(self, input_edges: list[Edge]) -> list[Any]:
+ item_edges = self._sort_collect_input_edges(input_edges, ITEM_FIELD)
+ collection_edges = self._sort_collect_input_edges(input_edges, COLLECTION_FIELD)
+
+ output_collection = []
+ for edge in collection_edges:
+ source_value = self._get_copied_result_value(edge)
+ if isinstance(source_value, list):
+ output_collection.extend(source_value)
+ else:
+ output_collection.append(source_value)
+ output_collection.extend(self._get_copied_result_value(edge) for edge in item_edges)
+ return output_collection
+
+ def _set_node_inputs(
+ self, node: BaseInvocation, input_edges: list[Edge], allowed_fields: Optional[set[str]] = None
+ ) -> None:
+ for edge in input_edges:
+ if allowed_fields is not None and edge.destination.field not in allowed_fields:
+ continue
+ setattr(node, edge.destination.field, self._get_copied_result_value(edge))
+
+ def _prepare_collect_inputs(self, node: "CollectInvocation", input_edges: list[Edge]) -> None:
+ node.collection = self._build_collect_collection(input_edges)
+
+ def _prepare_if_inputs(self, node: IfInvocation, input_edges: list[Edge]) -> None:
+ selected_field = self._state._resolved_if_exec_branches.get(node.id)
+ allowed_fields = {"condition", selected_field} if selected_field is not None else {"condition"}
+
+ for edge in input_edges:
+ if edge.destination.field not in allowed_fields:
+ continue
+
+ found_value, copied_value = self._try_get_copied_result_value(edge)
+ if not found_value:
+ iteration_path = self._state._get_iteration_path(node.id)
+ raise RuntimeError(
+ "IfInvocation selected input edge points at an exec node with no stored result output: "
+ f"if_exec_id={node.id}, source_exec_id={edge.source.node_id}, iteration_path={iteration_path}"
+ )
+
+ setattr(node, edge.destination.field, copied_value)
+
+ def _prepare_default_inputs(self, node: BaseInvocation, input_edges: list[Edge]) -> None:
+ self._set_node_inputs(node, input_edges)
+
+ def prepare_inputs(self, node: BaseInvocation) -> None:
+ input_edges = self._state.execution_graph._get_input_edges(node.id)
+
+ if isinstance(node, CollectInvocation):
+ self._prepare_collect_inputs(node, input_edges)
+ return
+
+ if isinstance(node, IfInvocation):
+ self._prepare_if_inputs(node, input_edges)
+ return
+
+ self._prepare_default_inputs(node, input_edges)
+
+
+def get_output_field_type(node: BaseInvocation, field: str) -> Any:
+ # TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which
+ # really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this
+ # would require some fairly significant changes and I don't want risk breaking anything.
+ try:
+ invocation_class = type(node)
+ invocation_output_class = invocation_class.get_output_annotation()
+ field_info = invocation_output_class.model_fields.get(field)
+ assert field_info is not None, f"Output field '{field}' not found in {invocation_output_class.get_type()}"
+ output_field_type = field_info.annotation
+ return output_field_type
+ except Exception:
+ return None
+
+
+def get_input_field_type(node: BaseInvocation, field: str) -> Any:
+ # TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which
+ # really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this
+ # would require some fairly significant changes and I don't want risk breaking anything.
+ try:
+ invocation_class = type(node)
+ field_info = invocation_class.model_fields.get(field)
+ assert field_info is not None, f"Input field '{field}' not found in {invocation_class.get_type()}"
+ input_field_type = field_info.annotation
+ return input_field_type
+ except Exception:
+ return None
def is_union_subtype(t1, t2):
@@ -93,45 +868,58 @@ def is_list_or_contains_list(t):
return False
+def is_any(t: Any) -> bool:
+ return t == Any or Any in get_args(t)
+
+
+def extract_collection_item_types(t: Any) -> set[Any]:
+ """Extracts list item types from a collection annotation, including unions containing list branches."""
+ if is_any(t):
+ return {Any}
+
+ if get_origin(t) is list:
+ return {arg for arg in get_args(t) if arg != NoneType}
+
+ item_types: set[Any] = set()
+ for arg in get_args(t):
+ if is_any(arg):
+ item_types.add(Any)
+ elif get_origin(arg) is list:
+ item_types.update(item_arg for item_arg in get_args(arg) if item_arg != NoneType)
+ return item_types
+
+
def are_connection_types_compatible(from_type: Any, to_type: Any) -> bool:
- if not from_type:
- return False
- if not to_type:
+ if not from_type or not to_type:
return False
- # TODO: this is pretty forgiving on generic types. Clean that up (need to handle optionals and such)
- if from_type and to_type:
- # Ports are compatible
- if (
- from_type == to_type
- or from_type == Any
- or to_type == Any
- or Any in get_args(from_type)
- or Any in get_args(to_type)
- ):
- return True
+ # Ports are compatible
+ if from_type == to_type or is_any(from_type) or is_any(to_type):
+ return True
- if from_type in get_args(to_type):
- return True
+ if from_type in get_args(to_type):
+ return True
- if to_type in get_args(from_type):
- return True
+ if to_type in get_args(from_type):
+ return True
- # allow int -> float, pydantic will cast for us
- if from_type is int and to_type is float:
- return True
+ # allow int -> float, pydantic will cast for us
+ if from_type is int and to_type is float:
+ return True
- # allow int|float -> str, pydantic will cast for us
- if (from_type is int or from_type is float) and to_type is str:
- return True
+ # allow int|float -> str, pydantic will cast for us
+ if (from_type is int or from_type is float) and to_type is str:
+ return True
- # if not issubclass(from_type, to_type):
- if not is_union_subtype(from_type, to_type):
- return False
- else:
- return False
+ # Prefer issubclass when both are real classes
+ try:
+ if isinstance(from_type, type) and isinstance(to_type, type):
+ return issubclass(from_type, to_type)
+ except TypeError:
+ pass
- return True
+ # Union-to-Union (or Union-to-non-Union) handling
+ return is_union_subtype(from_type, to_type)
def are_connections_compatible(
@@ -140,10 +928,10 @@ def are_connections_compatible(
"""Determines if a connection between fields of two nodes is compatible."""
# TODO: handle iterators and collectors
- from_node_field = get_output_field(from_node, from_field)
- to_node_field = get_input_field(to_node, to_field)
+ from_type = get_output_field_type(from_node, from_field)
+ to_type = get_input_field_type(to_node, to_field)
- return are_connection_types_compatible(from_node_field, to_node_field)
+ return are_connection_types_compatible(from_type, to_type)
T = TypeVar("T")
@@ -258,7 +1046,7 @@ class CollectInvocationOutput(BaseInvocationOutput):
)
-@invocation("collect", version="1.0.0")
+@invocation("collect", version="1.1.0")
class CollectInvocation(BaseInvocation):
"""Collects values into a collection"""
@@ -270,7 +1058,10 @@ class CollectInvocation(BaseInvocation):
input=Input.Connection,
)
collection: list[Any] = InputField(
- description="The collection, will be provided on execution", default=[], ui_hidden=True
+ description="An optional collection to append to",
+ default=[],
+ ui_type=UIType._Collection,
+ input=Input.Connection,
)
def invoke(self, context: InvocationContext) -> CollectInvocationOutput:
@@ -282,7 +1073,7 @@ class AnyInvocation(BaseInvocation):
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
def validate_invocation(v: Any) -> "AnyInvocation":
- return BaseInvocation.get_typeadapter().validate_python(v)
+ return InvocationRegistry.get_invocation_typeadapter().validate_python(v)
return core_schema.no_info_plain_validator_function(validate_invocation)
@@ -293,7 +1084,7 @@ def __get_pydantic_json_schema__(
# Nodes are too powerful, we have to make our own OpenAPI schema manually
# No but really, because the schema is dynamic depending on loaded nodes, we need to generate it manually
oneOf: list[dict[str, str]] = []
- names = [i.__name__ for i in BaseInvocation.get_invocations()]
+ names = [i.__name__ for i in InvocationRegistry.get_invocation_classes()]
for name in sorted(names):
oneOf.append({"$ref": f"#/components/schemas/{name}"})
return {"oneOf": oneOf}
@@ -303,7 +1094,7 @@ class AnyInvocationOutput(BaseInvocationOutput):
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler):
def validate_invocation_output(v: Any) -> "AnyInvocationOutput":
- return BaseInvocationOutput.get_typeadapter().validate_python(v)
+ return InvocationRegistry.get_output_typeadapter().validate_python(v)
return core_schema.no_info_plain_validator_function(validate_invocation_output)
@@ -315,13 +1106,15 @@ def __get_pydantic_json_schema__(
# No but really, because the schema is dynamic depending on loaded nodes, we need to generate it manually
oneOf: list[dict[str, str]] = []
- names = [i.__name__ for i in BaseInvocationOutput.get_outputs()]
+ names = [i.__name__ for i in InvocationRegistry.get_output_classes()]
for name in sorted(names):
oneOf.append({"$ref": f"#/components/schemas/{name}"})
return {"oneOf": oneOf}
class Graph(BaseModel):
+ """A validated invocation graph made of nodes and typed edges."""
+
id: str = Field(description="The id of this graph", default_factory=uuid_string)
# TODO: use a list (and never use dict in a BaseModel) because pydantic/fastapi hates me
nodes: dict[str, AnyInvocation] = Field(description="The nodes in this graph", default_factory=dict)
@@ -377,35 +1170,22 @@ def delete_edge(self, edge: Edge) -> None:
try:
self.edges.remove(edge)
- except KeyError:
+ except ValueError:
pass
- def validate_self(self) -> None:
- """
- Validates the graph.
-
- Raises an exception if the graph is invalid:
- - `DuplicateNodeIdError`
- - `NodeIdMismatchError`
- - `InvalidSubGraphError`
- - `NodeNotFoundError`
- - `NodeFieldNotFoundError`
- - `CyclicalGraphError`
- - `InvalidEdgeError`
- """
-
- # Validate that all node ids are unique
+ def _validate_unique_node_ids(self) -> None:
node_ids = [n.id for n in self.nodes.values()]
- duplicate_node_ids = {node_id for node_id in node_ids if node_ids.count(node_id) >= 2}
+ seen = set()
+ duplicate_node_ids = {nid for nid in node_ids if (nid in seen) or seen.add(nid)}
if duplicate_node_ids:
raise DuplicateNodeIdError(f"Node ids must be unique, found duplicates {duplicate_node_ids}")
- # Validate that all node ids match the keys in the nodes dict
- for k, v in self.nodes.items():
- if k != v.id:
- raise NodeIdMismatchError(f"Node ids must match, got {k} and {v.id}")
+ def _validate_node_id_mapping(self) -> None:
+ for node_dict_id, node in self.nodes.items():
+ if node_dict_id != node.id:
+ raise NodeIdMismatchError(f"Node ids must match, got {node_dict_id} and {node.id}")
- # Validate that all edges match nodes and fields in the graph
+ def _validate_edge_nodes_and_fields(self) -> None:
for edge in self.edges:
source_node = self.nodes.get(edge.source.node_id, None)
if source_node is None:
@@ -415,24 +1195,22 @@ def validate_self(self) -> None:
if destination_node is None:
raise NodeNotFoundError(f"Edge destination node {edge.destination.node_id} does not exist in the graph")
- # output fields are not on the node object directly, they are on the output type
if edge.source.field not in source_node.get_output_annotation().model_fields:
raise NodeFieldNotFoundError(
f"Edge source field {edge.source.field} does not exist in node {edge.source.node_id}"
)
- # input fields are on the node
- if edge.destination.field not in destination_node.model_fields:
+ if edge.destination.field not in type(destination_node).model_fields:
raise NodeFieldNotFoundError(
f"Edge destination field {edge.destination.field} does not exist in node {edge.destination.node_id}"
)
- # Validate there are no cycles
- g = self.nx_graph_flat()
- if not nx.is_directed_acyclic_graph(g):
+ def _validate_graph_is_acyclic(self) -> None:
+ graph = self.nx_graph_flat()
+ if not nx.is_directed_acyclic_graph(graph):
raise CyclicalGraphError("Graph contains cycles")
- # Validate all edge connections are valid
+ def _validate_edge_type_compatibility(self) -> None:
for edge in self.edges:
if not are_connections_compatible(
self.get_node(edge.source.node_id),
@@ -440,18 +1218,40 @@ def validate_self(self) -> None:
self.get_node(edge.destination.node_id),
edge.destination.field,
):
- raise InvalidEdgeError(
- f"Invalid edge from {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
+ raise InvalidEdgeError(f"Edge source and target types do not match ({edge})")
- # Validate all iterators & collectors
+ def _validate_special_nodes(self) -> None:
# TODO: may need to validate all iterators & collectors in subgraphs so edge connections in parent graphs will be available
for node in self.nodes.values():
- if isinstance(node, IterateInvocation) and not self._is_iterator_connection_valid(node.id):
- raise InvalidEdgeError(f"Invalid iterator node {node.id}")
- if isinstance(node, CollectInvocation) and not self._is_collector_connection_valid(node.id):
- raise InvalidEdgeError(f"Invalid collector node {node.id}")
+ if isinstance(node, IterateInvocation):
+ err = self._is_iterator_connection_valid(node.id)
+ if err is not None:
+ raise InvalidEdgeError(f"Invalid iterator node ({node.id}): {err}")
+ if isinstance(node, CollectInvocation):
+ err = self._is_collector_connection_valid(node.id)
+ if err is not None:
+ raise InvalidEdgeError(f"Invalid collector node ({node.id}): {err}")
+ def validate_self(self) -> None:
+ """
+ Validates the graph.
+
+ Raises an exception if the graph is invalid:
+ - `DuplicateNodeIdError`
+ - `NodeIdMismatchError`
+ - `InvalidSubGraphError`
+ - `NodeNotFoundError`
+ - `NodeFieldNotFoundError`
+ - `CyclicalGraphError`
+ - `InvalidEdgeError`
+ """
+
+ self._validate_unique_node_ids()
+ self._validate_node_id_mapping()
+ self._validate_edge_nodes_and_fields()
+ self._validate_graph_is_acyclic()
+ self._validate_edge_type_compatibility()
+ self._validate_special_nodes()
return None
def is_valid(self) -> bool:
@@ -477,81 +1277,78 @@ def is_valid(self) -> bool:
def _is_destination_field_Any(self, edge: Edge) -> bool:
"""Checks if the destination field for an edge is of type typing.Any"""
- return get_input_field(self.get_node(edge.destination.node_id), edge.destination.field) == Any
+ return get_input_field_type(self.get_node(edge.destination.node_id), edge.destination.field) == Any
def _is_destination_field_list_of_Any(self, edge: Edge) -> bool:
"""Checks if the destination field for an edge is of type typing.Any"""
- return get_input_field(self.get_node(edge.destination.node_id), edge.destination.field) == list[Any]
+ return get_input_field_type(self.get_node(edge.destination.node_id), edge.destination.field) == list[Any]
- def _validate_edge(self, edge: Edge):
- """Validates that a new edge doesn't create a cycle in the graph"""
-
- # Validate that the nodes exist
+ def _get_edge_nodes(self, edge: Edge) -> tuple[BaseInvocation, BaseInvocation]:
try:
- from_node = self.get_node(edge.source.node_id)
- to_node = self.get_node(edge.destination.node_id)
+ return self.get_node(edge.source.node_id), self.get_node(edge.destination.node_id)
except NodeNotFoundError:
- raise InvalidEdgeError("One or both nodes don't exist: {edge.source.node_id} -> {edge.destination.node_id}")
+ raise InvalidEdgeError(f"One or both nodes don't exist ({edge})")
- # Validate that an edge to this node+field doesn't already exist
+ def _validate_edge_destination_uniqueness(self, edge: Edge, destination_node: BaseInvocation) -> None:
input_edges = self._get_input_edges(edge.destination.node_id, edge.destination.field)
- if len(input_edges) > 0 and not isinstance(to_node, CollectInvocation):
- raise InvalidEdgeError(
- f"Edge to node {edge.destination.node_id} field {edge.destination.field} already exists"
- )
-
- # Validate that no cycles would be created
- g = self.nx_graph_flat()
- g.add_edge(edge.source.node_id, edge.destination.node_id)
- if not nx.is_directed_acyclic_graph(g):
- raise InvalidEdgeError(
- f"Edge creates a cycle in the graph: {edge.source.node_id} -> {edge.destination.node_id}"
- )
-
- # Validate that the field types are compatible
- if not are_connections_compatible(from_node, edge.source.field, to_node, edge.destination.field):
- raise InvalidEdgeError(
- f"Fields are incompatible: cannot connect {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
-
- # Validate if iterator output type matches iterator input type (if this edge results in both being set)
- if isinstance(to_node, IterateInvocation) and edge.destination.field == "collection":
- if not self._is_iterator_connection_valid(edge.destination.node_id, new_input=edge.source):
- raise InvalidEdgeError(
- f"Iterator input type does not match iterator output type: {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
-
- # Validate if iterator input type matches output type (if this edge results in both being set)
- if isinstance(from_node, IterateInvocation) and edge.source.field == "item":
- if not self._is_iterator_connection_valid(edge.source.node_id, new_output=edge.destination):
- raise InvalidEdgeError(
- f"Iterator output type does not match iterator input type:, {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
-
- # Validate if collector input type matches output type (if this edge results in both being set)
- if isinstance(to_node, CollectInvocation) and edge.destination.field == "item":
- if not self._is_collector_connection_valid(edge.destination.node_id, new_input=edge.source):
- raise InvalidEdgeError(
- f"Collector output type does not match collector input type: {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
-
- # Validate that we are not connecting collector to iterator (currently unsupported)
- if isinstance(from_node, CollectInvocation) and isinstance(to_node, IterateInvocation):
- raise InvalidEdgeError(
- f"Cannot connect collector to iterator: {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
+ if len(input_edges) > 0 and (
+ not isinstance(destination_node, CollectInvocation) or edge.destination.field != ITEM_FIELD
+ ):
+ raise InvalidEdgeError(f"Edge already exists ({edge})")
+
+ def _validate_edge_would_not_create_cycle(self, edge: Edge) -> None:
+ graph = self.nx_graph_flat()
+ graph.add_edge(edge.source.node_id, edge.destination.node_id)
+ if not nx.is_directed_acyclic_graph(graph):
+ raise InvalidEdgeError(f"Edge creates a cycle in the graph ({edge})")
+
+ def _validate_edge_field_compatibility(
+ self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation
+ ) -> None:
+ if not are_connections_compatible(source_node, edge.source.field, destination_node, edge.destination.field):
+ raise InvalidEdgeError(f"Field types are incompatible ({edge})")
+
+ def _validate_iterator_edge_rules(
+ self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation
+ ) -> None:
+ if isinstance(destination_node, IterateInvocation) and edge.destination.field == COLLECTION_FIELD:
+ err = self._is_iterator_connection_valid(edge.destination.node_id, new_input=edge.source)
+ if err is not None:
+ raise InvalidEdgeError(f"Iterator input type does not match iterator output type ({edge}): {err}")
+
+ if isinstance(source_node, IterateInvocation) and edge.source.field == ITEM_FIELD:
+ err = self._is_iterator_connection_valid(edge.source.node_id, new_output=edge.destination)
+ if err is not None:
+ raise InvalidEdgeError(f"Iterator output type does not match iterator input type ({edge}): {err}")
+
+ def _validate_collector_edge_rules(
+ self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation
+ ) -> None:
+ if isinstance(destination_node, CollectInvocation) and edge.destination.field in (ITEM_FIELD, COLLECTION_FIELD):
+ err = self._is_collector_connection_valid(
+ edge.destination.node_id, new_input=edge.source, new_input_field=edge.destination.field
)
+ if err is not None:
+ raise InvalidEdgeError(f"Collector output type does not match collector input type ({edge}): {err}")
- # Validate if collector output type matches input type (if this edge results in both being set) - skip if the destination field is not Any or list[Any]
if (
- isinstance(from_node, CollectInvocation)
- and edge.source.field == "collection"
+ isinstance(source_node, CollectInvocation)
+ and edge.source.field == COLLECTION_FIELD
and not self._is_destination_field_list_of_Any(edge)
and not self._is_destination_field_Any(edge)
):
- if not self._is_collector_connection_valid(edge.source.node_id, new_output=edge.destination):
- raise InvalidEdgeError(
- f"Collector input type does not match collector output type: {edge.source.node_id}.{edge.source.field} to {edge.destination.node_id}.{edge.destination.field}"
- )
+ err = self._is_collector_connection_valid(edge.source.node_id, new_output=edge.destination)
+ if err is not None:
+ raise InvalidEdgeError(f"Collector input type does not match collector output type ({edge}): {err}")
+
+ def _validate_edge(self, edge: Edge):
+ """Validates that a new edge doesn't create a cycle in the graph"""
+ source_node, destination_node = self._get_edge_nodes(edge)
+ self._validate_edge_destination_uniqueness(edge, destination_node)
+ self._validate_edge_would_not_create_cycle(edge)
+ self._validate_edge_field_compatibility(edge, source_node, destination_node)
+ self._validate_iterator_edge_rules(edge, source_node, destination_node)
+ self._validate_collector_edge_rules(edge, source_node, destination_node)
def has_node(self, node_id: str) -> bool:
"""Determines whether or not a node exists in the graph."""
@@ -634,81 +1431,264 @@ def _is_iterator_connection_valid(
node_id: str,
new_input: Optional[EdgeConnection] = None,
new_output: Optional[EdgeConnection] = None,
- ) -> bool:
- inputs = [e.source for e in self._get_input_edges(node_id, "collection")]
- outputs = [e.destination for e in self._get_output_edges(node_id, "item")]
+ ) -> str | None:
+ inputs = [e.source for e in self._get_input_edges(node_id, COLLECTION_FIELD)]
+ outputs = [e.destination for e in self._get_output_edges(node_id, ITEM_FIELD)]
if new_input is not None:
inputs.append(new_input)
if new_output is not None:
outputs.append(new_output)
- # Only one input is allowed for iterators
+ return self._validate_iterator_connections(inputs, outputs)
+
+ def _validate_iterator_connections(self, inputs: list[EdgeConnection], outputs: list[EdgeConnection]) -> str | None:
+ presence_error = self._validate_iterator_input_presence(inputs)
+ if presence_error is not None:
+ return presence_error
+
+ input_node = self.get_node(inputs[0].node_id)
+ input_field_type = get_output_field_type(input_node, inputs[0].field)
+ output_field_types = self._get_iterator_output_field_types(outputs)
+
+ input_type_error = self._validate_iterator_input_type(input_field_type)
+ if input_type_error is not None:
+ return input_type_error
+
+ output_type_error = self._validate_iterator_output_types(input_field_type, output_field_types)
+ if output_type_error is not None:
+ return output_type_error
+
+ return self._validate_iterator_collector_input(input_node, output_field_types)
+
+ def _validate_iterator_input_presence(self, inputs: list[EdgeConnection]) -> str | None:
+ if len(inputs) == 0:
+ return "Iterator must have a collection input edge"
if len(inputs) > 1:
- return False
+ return "Iterator may only have one input edge"
+ return None
- # Get input and output fields (the fields linked to the iterator's input/output)
- input_field = get_output_field(self.get_node(inputs[0].node_id), inputs[0].field)
- output_fields = [get_input_field(self.get_node(e.node_id), e.field) for e in outputs]
+ def _get_iterator_output_field_types(self, outputs: list[EdgeConnection]) -> list[Any]:
+ return [get_input_field_type(self.get_node(e.node_id), e.field) for e in outputs]
- # Input type must be a list
- if get_origin(input_field) != list:
- return False
+ def _validate_iterator_input_type(self, input_field_type: Any) -> str | None:
+ if get_origin(input_field_type) is not list:
+ return "Iterator input must be a collection"
+ return None
- # Validate that all outputs match the input type
- input_field_item_type = get_args(input_field)[0]
- if not all((are_connection_types_compatible(input_field_item_type, f) for f in output_fields)):
- return False
+ def _validate_iterator_output_types(self, input_field_type: Any, output_field_types: list[Any]) -> str | None:
+ input_field_item_type = get_args(input_field_type)[0]
+ if not all(are_connection_types_compatible(input_field_item_type, t) for t in output_field_types):
+ return "Iterator outputs must connect to an input with a matching type"
+ return None
- return True
+ def _validate_iterator_collector_input(
+ self, input_node: BaseInvocation, output_field_types: list[Any]
+ ) -> str | None:
+ if not isinstance(input_node, CollectInvocation):
+ return None
- def _is_collector_connection_valid(
+ input_root_type = self._get_collector_input_root_type(input_node.id)
+ if input_root_type is None:
+ return "Iterator input collector must have at least one item or collection input edge"
+ if not all(are_connection_types_compatible(input_root_type, t) for t in output_field_types):
+ return "Iterator collection type must match all iterator output types"
+ return None
+
+ def _resolve_collector_input_types(self, node_id: str, visited: Optional[set[str]] = None) -> set[Any]:
+ """Resolves possible item types for a collector's inputs, recursively following chained collectors."""
+ visited = visited or set()
+ if node_id in visited:
+ return set()
+ visited.add(node_id)
+
+ input_types: set[Any] = set()
+
+ for edge in self._get_input_edges(node_id, ITEM_FIELD):
+ input_field_type = get_output_field_type(self.get_node(edge.source.node_id), edge.source.field)
+ resolved_types = [input_field_type] if get_origin(input_field_type) is None else get_args(input_field_type)
+ input_types.update(t for t in resolved_types if t != NoneType)
+
+ for edge in self._get_input_edges(node_id, COLLECTION_FIELD):
+ source_node = self.get_node(edge.source.node_id)
+ if isinstance(source_node, CollectInvocation) and edge.source.field == COLLECTION_FIELD:
+ input_types.update(self._resolve_collector_input_types(source_node.id, visited.copy()))
+ continue
+
+ input_field_type = get_output_field_type(source_node, edge.source.field)
+ input_types.update(extract_collection_item_types(input_field_type))
+
+ return input_types
+
+ def _get_type_tree_root_types(self, input_types: set[Any]) -> list[Any]:
+ type_tree = nx.DiGraph()
+ type_tree.add_nodes_from(input_types)
+ type_tree.add_edges_from([e for e in itertools.permutations(input_types, 2) if issubclass(e[1], e[0])])
+ type_degrees = type_tree.in_degree(type_tree.nodes)
+ return [t[0] for t in type_degrees if t[1] == 0] # type: ignore
+
+ def _get_collector_input_root_type(self, node_id: str) -> Any | None:
+ input_types = self._resolve_collector_input_types(node_id)
+ non_any_input_types = {t for t in input_types if t != Any}
+ if len(non_any_input_types) == 0 and Any in input_types:
+ return Any
+ if len(non_any_input_types) == 0:
+ return None
+
+ root_types = self._get_type_tree_root_types(non_any_input_types)
+ if len(root_types) != 1:
+ return Any
+ return root_types[0]
+
+ def _get_collector_connections(
self,
node_id: str,
new_input: Optional[EdgeConnection] = None,
+ new_input_field: Optional[str] = None,
new_output: Optional[EdgeConnection] = None,
- ) -> bool:
- inputs = [e.source for e in self._get_input_edges(node_id, "item")]
- outputs = [e.destination for e in self._get_output_edges(node_id, "collection")]
+ ) -> tuple[list[EdgeConnection], list[EdgeConnection], list[EdgeConnection]]:
+ item_inputs = [e.source for e in self._get_input_edges(node_id, ITEM_FIELD)]
+ collection_inputs = [e.source for e in self._get_input_edges(node_id, COLLECTION_FIELD)]
+ outputs = [e.destination for e in self._get_output_edges(node_id, COLLECTION_FIELD)]
if new_input is not None:
- inputs.append(new_input)
+ field = new_input_field or ITEM_FIELD
+ if field == ITEM_FIELD:
+ item_inputs.append(new_input)
+ elif field == COLLECTION_FIELD:
+ collection_inputs.append(new_input)
+
if new_output is not None:
outputs.append(new_output)
- # Get input and output fields (the fields linked to the iterator's input/output)
- input_fields = [get_output_field(self.get_node(e.node_id), e.field) for e in inputs]
- output_fields = [get_input_field(self.get_node(e.node_id), e.field) for e in outputs]
-
- # Validate that all inputs are derived from or match a single type
- input_field_types = {
- t
- for input_field in input_fields
- for t in ([input_field] if get_origin(input_field) is None else get_args(input_field))
- if t != NoneType
- } # Get unique types
- type_tree = nx.DiGraph()
- type_tree.add_nodes_from(input_field_types)
- type_tree.add_edges_from([e for e in itertools.permutations(input_field_types, 2) if issubclass(e[1], e[0])])
- type_degrees = type_tree.in_degree(type_tree.nodes)
- if sum((t[1] == 0 for t in type_degrees)) != 1: # type: ignore
- return False # There is more than one root type
+ return item_inputs, collection_inputs, outputs
- # Get the input root type
- input_root_type = next(t[0] for t in type_degrees if t[1] == 0) # type: ignore
+ def _get_collector_port_types(
+ self,
+ item_inputs: list[EdgeConnection],
+ collection_inputs: list[EdgeConnection],
+ outputs: list[EdgeConnection],
+ ) -> tuple[list[Any], list[Any], list[Any]]:
+ item_input_field_types = [get_output_field_type(self.get_node(e.node_id), e.field) for e in item_inputs]
+ collection_input_field_types = [
+ get_output_field_type(self.get_node(e.node_id), e.field) for e in collection_inputs
+ ]
+ output_field_types = [get_input_field_type(self.get_node(e.node_id), e.field) for e in outputs]
+ return item_input_field_types, collection_input_field_types, output_field_types
+
+ def _resolve_item_input_types(self, item_input_field_types: list[Any]) -> set[Any]:
+ return {
+ resolved_type
+ for input_field_type in item_input_field_types
+ for resolved_type in (
+ [input_field_type] if get_origin(input_field_type) is None else get_args(input_field_type)
+ )
+ if resolved_type != NoneType
+ }
+
+ def _resolve_collection_input_types(
+ self, collection_inputs: list[EdgeConnection], collection_input_field_types: list[Any]
+ ) -> set[Any]:
+ input_field_types: set[Any] = set()
+ for input_conn, input_field_type in zip(collection_inputs, collection_input_field_types, strict=False):
+ source_node = self.get_node(input_conn.node_id)
+ if isinstance(source_node, CollectInvocation) and input_conn.field == COLLECTION_FIELD:
+ input_field_types.update(self._resolve_collector_input_types(source_node.id))
+ continue
+ input_field_types.update(extract_collection_item_types(input_field_type))
+ return input_field_types
+
+ def _validate_collector_collection_inputs(self, collection_input_field_types: list[Any]) -> str | None:
+ if not all((is_list_or_contains_list(t) or is_any(t) for t in collection_input_field_types)):
+ return "Collector collection input must be a collection"
+ return None
- # Verify that all outputs are lists
- if not all(is_list_or_contains_list(f) for f in output_fields):
- return False
+ def _get_collector_input_root_type_from_resolved_types(
+ self, input_field_types: set[Any]
+ ) -> tuple[bool, Any | None]:
+ non_any_input_field_types = {t for t in input_field_types if t != Any}
+ root_types = self._get_type_tree_root_types(non_any_input_field_types)
+ if len(root_types) > 1:
+ return True, None
+ return False, root_types[0] if len(root_types) == 1 else None
+
+ def _validate_collector_output_types(
+ self, output_field_types: list[Any], input_root_type: Any | None
+ ) -> str | None:
+ if not all(is_list_or_contains_list(t) or is_any(t) for t in output_field_types):
+ return "Collector output must connect to a collection input"
+
+ if input_root_type is not None:
+ if not all(
+ is_any(t)
+ or is_union_subtype(input_root_type, get_args(t)[0])
+ or issubclass(input_root_type, get_args(t)[0])
+ for t in output_field_types
+ ):
+ return "Collector outputs must connect to a collection input with a matching type"
+ elif any(not is_any(t) and get_args(t)[0] != Any for t in output_field_types):
+ return "Collector outputs must connect to a collection input with a matching type"
- # Verify that all outputs match the input type (are a base class or the same class)
- if not all(
- is_union_subtype(input_root_type, get_args(f)[0]) or issubclass(input_root_type, get_args(f)[0])
- for f in output_fields
- ):
- return False
+ return None
- return True
+ def _validate_downstream_collector_outputs(
+ self, outputs: list[EdgeConnection], input_root_type: Any | None
+ ) -> str | None:
+ for output in outputs:
+ output_node = self.get_node(output.node_id)
+ if not isinstance(output_node, CollectInvocation) or output.field != COLLECTION_FIELD:
+ continue
+ output_root_type = self._get_collector_input_root_type(output_node.id)
+ if output_root_type is None:
+ continue
+ if input_root_type is None:
+ if output_root_type != Any:
+ return "Collector outputs must connect to a collection input with a matching type"
+ continue
+ if not are_connection_types_compatible(input_root_type, output_root_type):
+ return "Collector outputs must connect to a collection input with a matching type"
+ return None
+
+ def _is_collector_connection_valid(
+ self,
+ node_id: str,
+ new_input: Optional[EdgeConnection] = None,
+ new_input_field: Optional[str] = None,
+ new_output: Optional[EdgeConnection] = None,
+ ) -> str | None:
+ item_inputs, collection_inputs, outputs = self._get_collector_connections(
+ node_id, new_input=new_input, new_input_field=new_input_field, new_output=new_output
+ )
+
+ if len(item_inputs) == 0 and len(collection_inputs) == 0:
+ return "Collector must have at least one item or collection input edge"
+
+ item_input_field_types, collection_input_field_types, output_field_types = self._get_collector_port_types(
+ item_inputs, collection_inputs, outputs
+ )
+
+ collection_input_error = self._validate_collector_collection_inputs(collection_input_field_types)
+ if collection_input_error is not None:
+ return collection_input_error
+
+ input_field_types = self._resolve_item_input_types(item_input_field_types)
+ input_field_types.update(self._resolve_collection_input_types(collection_inputs, collection_input_field_types))
+
+ has_multiple_root_types, input_root_type = self._get_collector_input_root_type_from_resolved_types(
+ input_field_types
+ )
+ if has_multiple_root_types:
+ return "Collector input collection items must be of a single type"
+
+ output_type_error = self._validate_collector_output_types(output_field_types, input_root_type)
+ if output_type_error is not None:
+ return output_type_error
+
+ downstream_output_error = self._validate_downstream_collector_outputs(outputs, input_root_type)
+ if downstream_output_error is not None:
+ return downstream_output_error
+
+ return None
def nx_graph(self) -> nx.DiGraph:
"""Returns a NetworkX DiGraph representing the layout of this graph"""
@@ -718,29 +1698,20 @@ def nx_graph(self) -> nx.DiGraph:
g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges})
return g
- def nx_graph_with_data(self) -> nx.DiGraph:
- """Returns a NetworkX DiGraph representing the data and layout of this graph"""
- g = nx.DiGraph()
- g.add_nodes_from(list(self.nodes.items()))
- g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges})
- return g
-
def nx_graph_flat(self, nx_graph: Optional[nx.DiGraph] = None) -> nx.DiGraph:
"""Returns a flattened NetworkX DiGraph, including all subgraphs (but not with iterations expanded)"""
g = nx_graph or nx.DiGraph()
# Add all nodes from this graph except graph/iteration nodes
- g.add_nodes_from([n.id for n in self.nodes.values() if not isinstance(n, IterateInvocation)])
-
- # TODO: figure out if iteration nodes need to be expanded
+ g.add_nodes_from([n.id for n in self.nodes.values()])
unique_edges = {(e.source.node_id, e.destination.node_id) for e in self.edges}
- g.add_edges_from([(e[0], e[1]) for e in unique_edges])
+ g.add_edges_from(unique_edges)
return g
class GraphExecutionState(BaseModel):
- """Tracks the state of a graph execution"""
+ """Tracks source-graph expansion, execution progress, and runtime results."""
id: str = Field(description="The id of the execution state", default_factory=uuid_string)
# TODO: Store a reference to the graph instead of the actual graph?
@@ -776,6 +1747,182 @@ class GraphExecutionState(BaseModel):
description="The map of original graph nodes to prepared nodes",
default_factory=dict,
)
+ # Ready queues grouped by node class name (internal only)
+ _ready_queues: dict[str, Deque[str]] = PrivateAttr(default_factory=dict)
+ # Current class being drained; stays until its queue empties
+ _active_class: Optional[str] = PrivateAttr(default=None)
+ # Optional priority; others follow in name order
+ ready_order: list[str] = Field(default_factory=list)
+ indegree: dict[str, int] = Field(default_factory=dict, description="Remaining unmet input count for exec nodes")
+ _iteration_path_cache: dict[str, tuple[int, ...]] = PrivateAttr(default_factory=dict)
+ _if_branch_exclusive_sources: dict[str, dict[str, set[str]]] = PrivateAttr(default_factory=dict)
+ _resolved_if_exec_branches: dict[str, str] = PrivateAttr(default_factory=dict)
+ _prepared_exec_metadata: dict[str, _PreparedExecNodeMetadata] = PrivateAttr(default_factory=dict)
+ _prepared_exec_registry: Optional[_PreparedExecRegistry] = PrivateAttr(default=None)
+ _if_branch_scheduler: Optional[_IfBranchScheduler] = PrivateAttr(default=None)
+ _execution_materializer: Optional[_ExecutionMaterializer] = PrivateAttr(default=None)
+ _execution_scheduler: Optional[_ExecutionScheduler] = PrivateAttr(default=None)
+ _execution_runtime: Optional[_ExecutionRuntime] = PrivateAttr(default=None)
+
+ def _type_key(self, node_obj: BaseInvocation) -> str:
+ return node_obj.__class__.__name__
+
+ def _prepared_registry(self) -> _PreparedExecRegistry:
+ if self._prepared_exec_registry is None:
+ self._prepared_exec_registry = _PreparedExecRegistry(
+ prepared_source_mapping=self.prepared_source_mapping,
+ source_prepared_mapping=self.source_prepared_mapping,
+ metadata=self._prepared_exec_metadata,
+ )
+ return self._prepared_exec_registry
+
+ def _if_scheduler(self) -> _IfBranchScheduler:
+ if self._if_branch_scheduler is None:
+ self._if_branch_scheduler = _IfBranchScheduler(self)
+ return self._if_branch_scheduler
+
+ def _materializer(self) -> _ExecutionMaterializer:
+ if self._execution_materializer is None:
+ self._execution_materializer = _ExecutionMaterializer(self)
+ return self._execution_materializer
+
+ def _scheduler(self) -> _ExecutionScheduler:
+ if self._execution_scheduler is None:
+ self._execution_scheduler = _ExecutionScheduler(self)
+ return self._execution_scheduler
+
+ def _runtime(self) -> _ExecutionRuntime:
+ if self._execution_runtime is None:
+ self._execution_runtime = _ExecutionRuntime(self)
+ return self._execution_runtime
+
+ def _register_prepared_exec_node(self, exec_node_id: str, source_node_id: str) -> None:
+ self._prepared_registry().register(exec_node_id, source_node_id)
+
+ def _get_prepared_exec_metadata(self, exec_node_id: str) -> _PreparedExecNodeMetadata:
+ return self._prepared_registry().get_metadata(exec_node_id)
+
+ def _set_prepared_exec_state(self, exec_node_id: str, state: PreparedExecState) -> None:
+ self._prepared_registry().set_state(exec_node_id, state)
+
+ def _get_iteration_path(self, exec_node_id: str) -> tuple[int, ...]:
+ return self._runtime().get_iteration_path(exec_node_id)
+
+ def _queue_for(self, cls_name: str) -> Deque[str]:
+ return self._scheduler().queue_for(cls_name)
+
+ def _is_deferred_by_unresolved_if(self, exec_node_id: str) -> bool:
+ return self._if_scheduler().is_deferred_by_unresolved_if(exec_node_id)
+
+ def _remove_from_ready_queues(self, exec_node_id: str) -> None:
+ self._scheduler().remove_from_ready_queues(exec_node_id)
+
+ def _try_resolve_if_node(self, exec_node_id: str) -> None:
+ self._if_scheduler().try_resolve_if_node(exec_node_id)
+
+ def set_ready_order(self, order: Iterable[Type[BaseInvocation] | str]) -> None:
+ names: list[str] = []
+ for x in order:
+ names.append(x.__name__ if hasattr(x, "__name__") else str(x))
+ self.ready_order = names
+
+ def _enqueue_if_ready(self, nid: str) -> None:
+ self._scheduler().enqueue_if_ready(nid)
+
+ def _prepare_until_node_ready(self) -> Optional[BaseInvocation]:
+ base_graph = self.graph.nx_graph_flat()
+ prepared_id = self._materializer().prepare(base_graph)
+ next_node: Optional[BaseInvocation] = None
+
+ while prepared_id is not None:
+ prepared_id = self._materializer().prepare(base_graph)
+ if next_node is None:
+ next_node = self._get_next_node()
+
+ return next_node
+
+ def _reset_runtime_caches(self) -> None:
+ self._ready_queues = {}
+ self._active_class = None
+ self._iteration_path_cache = {}
+ self._if_branch_exclusive_sources = {}
+ self._resolved_if_exec_branches = {}
+ self._prepared_exec_metadata = {}
+ self._prepared_exec_registry = None
+ self._if_branch_scheduler = None
+ self._execution_materializer = None
+ self._execution_scheduler = None
+ self._execution_runtime = None
+
+ def _rehydrate_prepared_exec_metadata(self) -> None:
+ registry = self._prepared_registry()
+ for exec_node_id, source_node_id in self.prepared_source_mapping.items():
+ metadata = registry.get_metadata(exec_node_id)
+ metadata.source_node_id = source_node_id
+ metadata.iteration_path = self._get_iteration_path(exec_node_id)
+ if exec_node_id in self.executed:
+ metadata.state = "executed" if exec_node_id in self.results else "skipped"
+ elif self.indegree.get(exec_node_id) == 0:
+ metadata.state = "ready"
+ else:
+ metadata.state = "pending"
+
+ def _apply_if_condition_inputs(self, exec_node_id: str, node: IfInvocation) -> bool:
+ condition_edges = self.execution_graph._get_input_edges(exec_node_id, "condition")
+ if any(edge.source.node_id not in self.executed for edge in condition_edges):
+ return False
+
+ for edge in condition_edges:
+ setattr(
+ node,
+ edge.destination.field,
+ copydeep(getattr(self.results[edge.source.node_id], edge.source.field)),
+ )
+ return True
+
+ def _rehydrate_resolved_if_exec_branches(self) -> None:
+ for exec_node_id, node in self.execution_graph.nodes.items():
+ if not isinstance(node, IfInvocation):
+ continue
+
+ if not self._apply_if_condition_inputs(exec_node_id, node):
+ continue
+
+ self._resolved_if_exec_branches[exec_node_id] = "true_input" if node.condition else "false_input"
+
+ def _rehydrate_ready_queues(self) -> None:
+ execution_graph = self.execution_graph.nx_graph_flat()
+ for exec_node_id in nx.topological_sort(execution_graph):
+ if exec_node_id in self.executed:
+ continue
+ if self.indegree.get(exec_node_id) != 0:
+ continue
+ self._enqueue_if_ready(exec_node_id)
+
+ def _rehydrate_runtime_state(self) -> None:
+ self._reset_runtime_caches()
+ self._rehydrate_prepared_exec_metadata()
+ self._rehydrate_resolved_if_exec_branches()
+ self._rehydrate_ready_queues()
+
+ def model_post_init(self, __context: Any) -> None:
+ self._rehydrate_runtime_state()
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "required": [
+ "id",
+ "graph",
+ "execution_graph",
+ "executed",
+ "executed_history",
+ "results",
+ "errors",
+ "prepared_source_mapping",
+ "source_prepared_mapping",
+ ]
+ }
+ )
@field_validator("graph")
def graph_is_valid(cls, v: Graph):
@@ -792,12 +1939,7 @@ def next(self) -> Optional[BaseInvocation]:
# If there are no prepared nodes, prepare some nodes
next_node = self._get_next_node()
if next_node is None:
- prepared_id = self._prepare()
-
- # Prepare as many nodes as we can
- while prepared_id is not None:
- prepared_id = self._prepare()
- next_node = self._get_next_node()
+ next_node = self._prepare_until_node_ready()
# Get values from edges
if next_node is not None:
@@ -811,21 +1953,7 @@ def next(self) -> Optional[BaseInvocation]:
def complete(self, node_id: str, output: BaseInvocationOutput) -> None:
"""Marks a node as complete"""
-
- if node_id not in self.execution_graph.nodes:
- return # TODO: log error?
-
- # Mark node as executed
- self.executed.add(node_id)
- self.results[node_id] = output
-
- # Check if source node is complete (all prepared nodes are complete)
- source_node = self.prepared_source_mapping[node_id]
- prepared_nodes = self.source_prepared_mapping[source_node]
-
- if all(n in self.executed for n in prepared_nodes):
- self.executed.add(source_node)
- self.executed_history.append(source_node)
+ self._scheduler().complete(node_id, output)
def set_node_error(self, node_id: str, error: str):
"""Marks a node as errored"""
@@ -841,160 +1969,16 @@ def has_error(self) -> bool:
return len(self.errors) > 0
def _create_execution_node(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[str]:
- """Prepares an iteration node and connects all edges, returning the new node id"""
-
- node = self.graph.get_node(node_id)
-
- self_iteration_count = -1
-
- # If this is an iterator node, we must create a copy for each iteration
- if isinstance(node, IterateInvocation):
- # Get input collection edge (should error if there are no inputs)
- input_collection_edge = next(iter(self.graph._get_input_edges(node_id, "collection")))
- input_collection_prepared_node_id = next(
- n[1] for n in iteration_node_map if n[0] == input_collection_edge.source.node_id
- )
- input_collection_prepared_node_output = self.results[input_collection_prepared_node_id]
- input_collection = getattr(input_collection_prepared_node_output, input_collection_edge.source.field)
- self_iteration_count = len(input_collection)
-
- new_nodes: list[str] = []
- if self_iteration_count == 0:
- # TODO: should this raise a warning? It might just happen if an empty collection is input, and should be valid.
- return new_nodes
-
- # Get all input edges
- input_edges = self.graph._get_input_edges(node_id)
-
- # Create new edges for this iteration
- # For collect nodes, this may contain multiple inputs to the same field
- new_edges: list[Edge] = []
- for edge in input_edges:
- for input_node_id in (n[1] for n in iteration_node_map if n[0] == edge.source.node_id):
- new_edge = Edge(
- source=EdgeConnection(node_id=input_node_id, field=edge.source.field),
- destination=EdgeConnection(node_id="", field=edge.destination.field),
- )
- new_edges.append(new_edge)
-
- # Create a new node (or one for each iteration of this iterator)
- for i in range(self_iteration_count) if self_iteration_count > 0 else [-1]:
- # Create a new node
- new_node = copy.deepcopy(node)
-
- # Create the node id (use a random uuid)
- new_node.id = uuid_string()
-
- # Set the iteration index for iteration invocations
- if isinstance(new_node, IterateInvocation):
- new_node.index = i
-
- # Add to execution graph
- self.execution_graph.add_node(new_node)
- self.prepared_source_mapping[new_node.id] = node_id
- if node_id not in self.source_prepared_mapping:
- self.source_prepared_mapping[node_id] = set()
- self.source_prepared_mapping[node_id].add(new_node.id)
-
- # Add new edges to execution graph
- for edge in new_edges:
- new_edge = Edge(
- source=edge.source,
- destination=EdgeConnection(node_id=new_node.id, field=edge.destination.field),
- )
- self.execution_graph.add_edge(new_edge)
-
- new_nodes.append(new_node.id)
+ return self._materializer().create_execution_node(node_id, iteration_node_map)
- return new_nodes
+ def _iterator_graph(self, base: Optional[nx.DiGraph] = None) -> nx.DiGraph:
+ return self._materializer().iterator_graph(base)
- def _iterator_graph(self) -> nx.DiGraph:
- """Gets a DiGraph with edges to collectors removed so an ancestor search produces all active iterators for any node"""
- g = self.graph.nx_graph_flat()
- collectors = (n for n in self.graph.nodes if isinstance(self.graph.get_node(n), CollectInvocation))
- for c in collectors:
- g.remove_edges_from(list(g.in_edges(c)))
- return g
-
- def _get_node_iterators(self, node_id: str) -> list[str]:
- """Gets iterators for a node"""
- g = self._iterator_graph()
- iterators = [n for n in nx.ancestors(g, node_id) if isinstance(self.graph.get_node(n), IterateInvocation)]
- return iterators
-
- def _prepare(self) -> Optional[str]:
- # Get flattened source graph
- g = self.graph.nx_graph_flat()
-
- # Find next node that:
- # - was not already prepared
- # - is not an iterate node whose inputs have not been executed
- # - does not have an unexecuted iterate ancestor
- sorted_nodes = nx.topological_sort(g)
- next_node_id = next(
- (
- n
- for n in sorted_nodes
- # exclude nodes that have already been prepared
- if n not in self.source_prepared_mapping
- # exclude iterate nodes whose inputs have not been executed
- and not (
- isinstance(self.graph.get_node(n), IterateInvocation) # `n` is an iterate node...
- and not all((e[0] in self.executed for e in g.in_edges(n))) # ...that has unexecuted inputs
- )
- # exclude nodes who have unexecuted iterate ancestors
- and not any(
- (
- isinstance(self.graph.get_node(a), IterateInvocation) # `a` is an iterate ancestor of `n`...
- and a not in self.executed # ...that is not executed
- for a in nx.ancestors(g, n) # for all ancestors `a` of node `n`
- )
- )
- ),
- None,
- )
-
- if next_node_id is None:
- return None
-
- # Get all parents of the next node
- next_node_parents = [e[0] for e in g.in_edges(next_node_id)]
-
- # Create execution nodes
- next_node = self.graph.get_node(next_node_id)
- new_node_ids = []
- if isinstance(next_node, CollectInvocation):
- # Collapse all iterator input mappings and create a single execution node for the collect invocation
- all_iteration_mappings = list(
- itertools.chain(*(((s, p) for p in self.source_prepared_mapping[s]) for s in next_node_parents))
- )
- # all_iteration_mappings = list(set(itertools.chain(*prepared_parent_mappings)))
- create_results = self._create_execution_node(next_node_id, all_iteration_mappings)
- if create_results is not None:
- new_node_ids.extend(create_results)
- else: # Iterators or normal nodes
- # Get all iterator combinations for this node
- # Will produce a list of lists of prepared iterator nodes, from which results can be iterated
- iterator_nodes = self._get_node_iterators(next_node_id)
- iterator_nodes_prepared = [list(self.source_prepared_mapping[n]) for n in iterator_nodes]
- iterator_node_prepared_combinations = list(itertools.product(*iterator_nodes_prepared))
-
- # Select the correct prepared parents for each iteration
- # For every iterator, the parent must either not be a child of that iterator, or must match the prepared iteration for that iterator
- # TODO: Handle a node mapping to none
- eg = self.execution_graph.nx_graph_flat()
- prepared_parent_mappings = [
- [(n, self._get_iteration_node(n, g, eg, it)) for n in next_node_parents]
- for it in iterator_node_prepared_combinations
- ] # type: ignore
-
- # Create execution node for each iteration
- for iteration_mappings in prepared_parent_mappings:
- create_results = self._create_execution_node(next_node_id, iteration_mappings) # type: ignore
- if create_results is not None:
- new_node_ids.extend(create_results)
+ def _get_node_iterators(self, node_id: str, it_graph: Optional[nx.DiGraph] = None) -> list[str]:
+ return self._materializer().get_node_iterators(node_id, it_graph)
- return next(iter(new_node_ids), None)
+ def _prepare(self, base_g: Optional[nx.DiGraph] = None) -> Optional[str]:
+ return self._materializer().prepare(base_g)
def _get_iteration_node(
self,
@@ -1003,74 +1987,13 @@ def _get_iteration_node(
execution_graph: nx.DiGraph,
prepared_iterator_nodes: list[str],
) -> Optional[str]:
- """Gets the prepared version of the specified source node that matches every iteration specified"""
- prepared_nodes = self.source_prepared_mapping[source_node_id]
- if len(prepared_nodes) == 1:
- return next(iter(prepared_nodes))
-
- # Check if the requested node is an iterator
- prepared_iterator = next((n for n in prepared_nodes if n in prepared_iterator_nodes), None)
- if prepared_iterator is not None:
- return prepared_iterator
-
- # Filter to only iterator nodes that are a parent of the specified node, in tuple format (prepared, source)
- iterator_source_node_mapping = [(n, self.prepared_source_mapping[n]) for n in prepared_iterator_nodes]
- parent_iterators = [itn for itn in iterator_source_node_mapping if nx.has_path(graph, itn[1], source_node_id)]
-
- return next(
- (n for n in prepared_nodes if all(nx.has_path(execution_graph, pit[0], n) for pit in parent_iterators)),
- None,
- )
+ return self._materializer().get_iteration_node(source_node_id, graph, execution_graph, prepared_iterator_nodes)
def _get_next_node(self) -> Optional[BaseInvocation]:
- """Gets the deepest node that is ready to be executed"""
- g = self.execution_graph.nx_graph()
-
- # Perform a topological sort using depth-first search
- topo_order = list(nx.dfs_postorder_nodes(g))
-
- # Get all IterateInvocation nodes
- iterate_nodes = [n for n in topo_order if isinstance(self.execution_graph.nodes[n], IterateInvocation)]
-
- # Sort the IterateInvocation nodes based on their index attribute
- iterate_nodes.sort(key=lambda x: self.execution_graph.nodes[x].index)
-
- # Prioritize IterateInvocation nodes and their children
- for iterate_node in iterate_nodes:
- if iterate_node not in self.executed and all((e[0] in self.executed for e in g.in_edges(iterate_node))):
- return self.execution_graph.nodes[iterate_node]
-
- # Check the children of the IterateInvocation node
- for child_node in nx.dfs_postorder_nodes(g, iterate_node):
- if child_node not in self.executed and all((e[0] in self.executed for e in g.in_edges(child_node))):
- return self.execution_graph.nodes[child_node]
-
- # If no IterateInvocation node or its children are ready, return the first ready node in the topological order
- for node in topo_order:
- if node not in self.executed and all((e[0] in self.executed for e in g.in_edges(node))):
- return self.execution_graph.nodes[node]
-
- # If no node is found, return None
- return None
+ return self._scheduler().get_next_node()
def _prepare_inputs(self, node: BaseInvocation):
- input_edges = [e for e in self.execution_graph.edges if e.destination.node_id == node.id]
- # Inputs must be deep-copied, else if a node mutates the object, other nodes that get the same input
- # will see the mutation.
- if isinstance(node, CollectInvocation):
- output_collection = [
- copydeep(getattr(self.results[edge.source.node_id], edge.source.field))
- for edge in input_edges
- if edge.destination.field == "item"
- ]
- node.collection = output_collection
- else:
- for edge in input_edges:
- setattr(
- node,
- edge.destination.field,
- copydeep(getattr(self.results[edge.source.node_id], edge.source.field)),
- )
+ self._runtime().prepare_inputs(node)
# TODO: Add API for modifying underlying graph that checks if the change will be valid given the current execution state
def _is_edge_valid(self, edge: Edge) -> bool:
diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py
index 01662335e46..e38766d5ba2 100644
--- a/invokeai/app/services/shared/invocation_context.py
+++ b/invokeai/app/services/shared/invocation_context.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional, Union
@@ -8,22 +9,20 @@
from invokeai.app.invocations.constants import IMAGE_MODES
from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata
+from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
from invokeai.app.services.images.images_common import ImageDTO
from invokeai.app.services.invocation_services import InvocationServices
from invokeai.app.services.model_records.model_records_base import UnknownModelException
-from invokeai.app.util.step_callback import stable_diffusion_step_callback
-from invokeai.backend.model_manager.config import (
- AnyModel,
- AnyModelConfig,
- BaseModelType,
- ModelFormat,
- ModelType,
- SubModelType,
-)
+from invokeai.app.services.session_processor.session_processor_common import ProgressImage
+from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
+from invokeai.app.util.step_callback import diffusion_step_callback
+from invokeai.backend.model_manager.configs.base import Config_Base
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.load.load_base import LoadedModel, LoadedModelWithoutConfig
+from invokeai.backend.model_manager.taxonomy import AnyModel, BaseModelType, ModelFormat, ModelType, SubModelType
from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
@@ -73,7 +72,7 @@ def __init__(self, services: InvocationServices, data: InvocationContextData) ->
class BoardsInterface(InvocationContextInterface):
def create(self, board_name: str) -> BoardDTO:
- """Creates a board.
+ """Creates a board for the current user.
Args:
board_name: The name of the board to create.
@@ -81,7 +80,8 @@ def create(self, board_name: str) -> BoardDTO:
Returns:
The created board DTO.
"""
- return self._services.boards.create(board_name)
+ user_id = self._data.queue_item.user_id
+ return self._services.boards.create(board_name, user_id)
def get_dto(self, board_id: str) -> BoardDTO:
"""Gets a board DTO.
@@ -95,12 +95,15 @@ def get_dto(self, board_id: str) -> BoardDTO:
return self._services.boards.get_dto(board_id)
def get_all(self) -> list[BoardDTO]:
- """Gets all boards.
+ """Gets all boards accessible to the current user.
Returns:
- A list of all boards.
+ A list of all boards accessible to the current user.
"""
- return self._services.boards.get_all()
+ user_id = self._data.queue_item.user_id
+ return self._services.boards.get_all(
+ user_id, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending
+ )
def add_image_to_board(self, board_id: str, image_name: str) -> None:
"""Adds an image to a board.
@@ -120,7 +123,11 @@ def get_all_image_names_for_board(self, board_id: str) -> list[str]:
Returns:
A list of all image names for the board.
"""
- return self._services.board_images.get_all_board_image_names_for_board(board_id)
+ return self._services.board_images.get_all_board_image_names_for_board(
+ board_id,
+ categories=None,
+ is_intermediate=None,
+ )
class LoggerInterface(InvocationContextInterface):
@@ -158,6 +165,10 @@ def error(self, message: str) -> None:
class ImagesInterface(InvocationContextInterface):
+ def __init__(self, services: InvocationServices, data: InvocationContextData, util: "UtilInterface") -> None:
+ super().__init__(services, data)
+ self._util = util
+
def save(
self,
image: Image,
@@ -184,6 +195,8 @@ def save(
The saved image DTO.
"""
+ self._util.signal_progress("Saving image")
+
# If `metadata` is provided directly, use that. Else, use the metadata provided by `WithMetadata`, falling back to None.
metadata_ = None
if metadata:
@@ -217,10 +230,11 @@ def save(
graph=graph_,
session_id=self._data.queue_item.session_id,
node_id=self._data.invocation.id,
+ user_id=self._data.queue_item.user_id,
)
def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image:
- """Gets an image as a PIL Image object.
+ """Gets an image as a PIL Image object. This method returns a copy of the image.
Args:
image_name: The name of the image to get.
@@ -232,11 +246,15 @@ def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image:
image = self._services.images.get_pil_image(image_name)
if mode and mode != image.mode:
try:
+ # convert makes a copy!
image = image.convert(mode)
except ValueError:
self._services.logger.warning(
f"Could not convert image from {image.mode} to {mode}. Using original mode instead."
)
+ else:
+ # copy the image to prevent the user from modifying the original
+ image = image.copy()
return image
def get_metadata(self, image_name: str) -> Optional[MetadataField]:
@@ -271,7 +289,7 @@ def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
Returns:
The local path of the image or thumbnail.
"""
- return self._services.images.get_path(image_name, thumbnail)
+ return Path(self._services.images.get_path(image_name, thumbnail))
class TensorsInterface(InvocationContextInterface):
@@ -289,15 +307,15 @@ def save(self, tensor: Tensor) -> str:
return name
def load(self, name: str) -> Tensor:
- """Loads a tensor by name.
+ """Loads a tensor by name. This method returns a copy of the tensor.
Args:
name: The name of the tensor to load.
Returns:
- The loaded tensor.
+ The tensor.
"""
- return self._services.tensors.load(name)
+ return self._services.tensors.load(name).clone()
class ConditioningInterface(InvocationContextInterface):
@@ -315,21 +333,25 @@ def save(self, conditioning_data: ConditioningFieldData) -> str:
return name
def load(self, name: str) -> ConditioningFieldData:
- """Loads conditioning data by name.
+ """Loads conditioning data by name. This method returns a copy of the conditioning data.
Args:
name: The name of the conditioning data to load.
Returns:
- The loaded conditioning data.
+ The conditioning data.
"""
- return self._services.conditioning.load(name)
+ return deepcopy(self._services.conditioning.load(name))
class ModelsInterface(InvocationContextInterface):
"""Common API for loading, downloading and managing models."""
+ def __init__(self, services: InvocationServices, data: InvocationContextData, util: "UtilInterface") -> None:
+ super().__init__(services, data)
+ self._util = util
+
def exists(self, identifier: Union[str, "ModelIdentifierField"]) -> bool:
"""Check if a model exists.
@@ -362,11 +384,17 @@ def load(
if isinstance(identifier, str):
model = self._services.model_manager.store.get_model(identifier)
- return self._services.model_manager.load.load_model(model, submodel_type)
else:
- _submodel_type = submodel_type or identifier.submodel_type
+ submodel_type = submodel_type or identifier.submodel_type
model = self._services.model_manager.store.get_model(identifier.key)
- return self._services.model_manager.load.load_model(model, _submodel_type)
+
+ self._raise_if_external(model)
+
+ message = f"Loading model {model.name}"
+ if submodel_type:
+ message += f" ({submodel_type.value})"
+ self._util.signal_progress(message)
+ return self._services.model_manager.load.load_model(model, submodel_type)
def load_by_attrs(
self, name: str, base: BaseModelType, type: ModelType, submodel_type: Optional[SubModelType] = None
@@ -391,8 +419,18 @@ def load_by_attrs(
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.
@@ -461,6 +499,7 @@ def download_and_cache_model(
Returns:
Path to the downloaded model
"""
+ self._util.signal_progress(f"Downloading model {source}")
return self._services.model_manager.install.download_and_cache_model(source=source)
def load_local_model(
@@ -483,6 +522,8 @@ def load_local_model(
Returns:
A LoadedModelWithoutConfig object.
"""
+
+ self._util.signal_progress(f"Loading model {model_path.name}")
return self._services.model_manager.load.load_model_from_path(model_path=model_path, loader=loader)
def load_remote_model(
@@ -508,8 +549,34 @@ def load_remote_model(
A LoadedModelWithoutConfig object.
"""
model_path = self._services.model_manager.install.download_and_cache_model(source=str(source))
+
+ self._util.signal_progress(f"Loading model {source}")
return self._services.model_manager.load.load_model_from_path(model_path=model_path, loader=loader)
+ def get_absolute_path(self, config_or_path: AnyModelConfig | Path | str) -> Path:
+ """Gets the absolute path for a given model config or path.
+
+ For example, if the model's path is `flux/main/FLUX Dev.safetensors`, and the models path is
+ `/home/username/InvokeAI/models`, this method will return
+ `/home/username/InvokeAI/models/flux/main/FLUX Dev.safetensors`.
+
+ Args:
+ config_or_path: The model config or path.
+
+ Returns:
+ The absolute path to the model.
+ """
+
+ model_path = Path(config_or_path.path) if isinstance(config_or_path, Config_Base) else Path(config_or_path)
+
+ if model_path.is_absolute():
+ return model_path.resolve()
+
+ base_models_path = self._services.configuration.models_path
+ joined_path = base_models_path / model_path
+ resolved_path = joined_path.resolve()
+ return resolved_path
+
class ConfigInterface(InvocationContextInterface):
def get(self) -> InvokeAIAppConfig:
@@ -549,14 +616,108 @@ def sd_step_callback(self, intermediate_state: PipelineIntermediateState, base_m
base_model: The base model for the current denoising step.
"""
- stable_diffusion_step_callback(
- context_data=self._data,
+ diffusion_step_callback(
+ signal_progress=self.signal_progress,
intermediate_state=intermediate_state,
base_model=base_model,
- events=self._services.events,
is_canceled=self.is_canceled,
)
+ def flux_step_callback(self, intermediate_state: PipelineIntermediateState) -> None:
+ """
+ The step callback emits a progress event with the current step, the total number of
+ steps, a preview image, and some other internal metadata.
+
+ This should be called after each denoising step.
+
+ Args:
+ intermediate_state: The intermediate state of the diffusion pipeline.
+ """
+
+ diffusion_step_callback(
+ signal_progress=self.signal_progress,
+ intermediate_state=intermediate_state,
+ base_model=BaseModelType.Flux,
+ is_canceled=self.is_canceled,
+ )
+
+ def flux2_step_callback(self, intermediate_state: PipelineIntermediateState) -> None:
+ """
+ The step callback for FLUX.2 Klein models (32-channel VAE).
+
+ Args:
+ intermediate_state: The intermediate state of the diffusion pipeline.
+ """
+
+ diffusion_step_callback(
+ signal_progress=self.signal_progress,
+ intermediate_state=intermediate_state,
+ base_model=BaseModelType.Flux2,
+ is_canceled=self.is_canceled,
+ )
+
+ def signal_progress(
+ self,
+ message: str,
+ percentage: float | None = None,
+ image: Image | None = None,
+ image_size: tuple[int, int] | None = None,
+ ) -> None:
+ """Signals the progress of some long-running invocation. The progress is displayed in the UI.
+
+ If a percentage is provided, the UI will display a progress bar and automatically append the percentage to the
+ message. You should not include the percentage in the message.
+
+ Example:
+ ```py
+ total_steps = 10
+ for i in range(total_steps):
+ percentage = i / (total_steps - 1)
+ context.util.signal_progress("Doing something cool", percentage)
+ ```
+
+ If an image is provided, the UI will display it. If your image should be displayed at a different size, provide
+ a tuple of `(width, height)` for the `image_size` parameter. The image will be displayed at the specified size
+ in the UI.
+
+ For example, SD denoising progress images are 1/8 the size of the original image, so you'd do this to ensure the
+ image is displayed at the correct size:
+ ```py
+ # Calculate the output size of the image (8x the progress image's size)
+ width = progress_image.width * 8
+ height = progress_image.height * 8
+ # Signal the progress with the image and output size
+ signal_progress("Denoising", percentage, progress_image, (width, height))
+ ```
+
+ If your progress image is very large, consider downscaling it to reduce the payload size and provide the original
+ size to the `image_size` parameter. The PIL `thumbnail` method is useful for this, as it maintains the aspect
+ ratio of the image:
+ ```py
+ # `thumbnail` modifies the image in-place, so we need to first make a copy
+ thumbnail_image = progress_image.copy()
+ # Resize the image to a maximum of 256x256 pixels, maintaining the aspect ratio
+ thumbnail_image.thumbnail((256, 256))
+ # Signal the progress with the thumbnail, passing the original size
+ signal_progress("Denoising", percentage, thumbnail, progress_image.size)
+ ```
+
+ Args:
+ message: A message describing the current status. Do not include the percentage in this message.
+ percentage: The current percentage completion for the process. Omit for indeterminate progress.
+ image: An optional image to display.
+ image_size: The optional size of the image to display. If omitted, the image will be displayed at its
+ original size.
+ """
+
+ self._services.events.emit_invocation_progress(
+ queue_item=self._data.queue_item,
+ invocation=self._data.invocation,
+ message=message,
+ percentage=percentage,
+ image=ProgressImage.build(image, image_size) if image else None,
+ )
+
class InvocationContext:
"""Provides access to various services and data for the current invocation.
@@ -623,12 +784,12 @@ def build_invocation_context(
"""
logger = LoggerInterface(services=services, data=data)
- images = ImagesInterface(services=services, data=data)
tensors = TensorsInterface(services=services, data=data)
- models = ModelsInterface(services=services, data=data)
config = ConfigInterface(services=services, data=data)
util = UtilInterface(services=services, data=data, is_canceled=is_canceled)
conditioning = ConditioningInterface(services=services, data=data)
+ models = ModelsInterface(services=services, data=data, util=util)
+ images = ImagesInterface(services=services, data=data, util=util)
boards = BoardsInterface(services=services, data=data)
ctx = InvocationContext(
diff --git a/invokeai/app/services/shared/sqlite/sqlite_database.py b/invokeai/app/services/shared/sqlite/sqlite_database.py
index e860160044e..e67aab0ea58 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_database.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_database.py
@@ -1,5 +1,7 @@
import sqlite3
import threading
+from collections.abc import Generator
+from contextlib import contextmanager
from logging import Logger
from pathlib import Path
@@ -27,41 +29,65 @@ class SqliteDatabase:
def __init__(self, db_path: Path | None, logger: Logger, verbose: bool = False) -> None:
"""Initializes the database. This is used internally by the class constructor."""
- self.logger = logger
- self.db_path = db_path
- self.verbose = verbose
+ self._logger = logger
+ self._db_path = db_path
+ self._verbose = verbose
+ self._lock = threading.RLock()
- if not self.db_path:
+ if not self._db_path:
logger.info("Initializing in-memory database")
else:
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
- self.logger.info(f"Initializing database at {self.db_path}")
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._logger.info(f"Initializing database at {self._db_path}")
- self.conn = sqlite3.connect(database=self.db_path or sqlite_memory, check_same_thread=False)
- self.lock = threading.RLock()
- self.conn.row_factory = sqlite3.Row
+ self._conn = sqlite3.connect(database=self._db_path or sqlite_memory, check_same_thread=False)
+ self._conn.row_factory = sqlite3.Row
- if self.verbose:
- self.conn.set_trace_callback(self.logger.debug)
+ if self._verbose:
+ self._conn.set_trace_callback(self._logger.debug)
- self.conn.execute("PRAGMA foreign_keys = ON;")
+ # Enable foreign key constraints
+ self._conn.execute("PRAGMA foreign_keys = ON;")
+
+ # Enable Write-Ahead Logging (WAL) mode for better concurrency
+ self._conn.execute("PRAGMA journal_mode = WAL;")
+
+ # Set a busy timeout to prevent database lockups during writes
+ self._conn.execute("PRAGMA busy_timeout = 5000;") # 5 seconds
def clean(self) -> None:
"""
Cleans the database by running the VACUUM command, reporting on the freed space.
"""
# No need to clean in-memory database
- if not self.db_path:
+ if not self._db_path:
return
- with self.lock:
- try:
- initial_db_size = Path(self.db_path).stat().st_size
- self.conn.execute("VACUUM;")
- self.conn.commit()
- final_db_size = Path(self.db_path).stat().st_size
+ try:
+ with self._conn as conn:
+ initial_db_size = Path(self._db_path).stat().st_size
+ conn.execute("VACUUM;")
+ conn.commit()
+ final_db_size = Path(self._db_path).stat().st_size
freed_space_in_mb = round((initial_db_size - final_db_size) / 1024 / 1024, 2)
if freed_space_in_mb > 0:
- self.logger.info(f"Cleaned database (freed {freed_space_in_mb}MB)")
- except Exception as e:
- self.logger.error(f"Error cleaning database: {e}")
+ self._logger.info(f"Cleaned database (freed {freed_space_in_mb}MB)")
+ except Exception as e:
+ self._logger.error(f"Error cleaning database: {e}")
+ raise
+
+ @contextmanager
+ def transaction(self) -> Generator[sqlite3.Cursor, None, None]:
+ """
+ Thread-safe context manager for DB work.
+ Acquires the RLock, yields a Cursor, then commits or rolls back.
+ """
+ with self._lock:
+ cursor = self._conn.cursor()
+ try:
+ yield cursor
+ self._conn.commit()
+ except Exception:
+ self._conn.rollback()
raise
+ finally:
+ cursor.close()
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 3b5f4473066..14b6c61a85a 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -14,6 +14,28 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_9 import build_migration_9
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_10 import build_migration_10
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_11 import build_migration_11
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_12 import build_migration_12
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_13 import build_migration_13
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import build_migration_14
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_15 import build_migration_15
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_16 import build_migration_16
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_17 import build_migration_17
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_18 import build_migration_18
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_19 import build_migration_19
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_20 import build_migration_20
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_21 import build_migration_21
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_22 import build_migration_22
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_23 import build_migration_23
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_24 import build_migration_24
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import build_migration_25
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import build_migration_31
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import build_migration_32
+from invokeai.app.services.shared.sqlite_migrator.migrations.migration_33 import build_migration_33
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@@ -45,6 +67,28 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_9())
migrator.register_migration(build_migration_10())
migrator.register_migration(build_migration_11(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_12(app_config=config))
+ migrator.register_migration(build_migration_13())
+ migrator.register_migration(build_migration_14())
+ migrator.register_migration(build_migration_15())
+ migrator.register_migration(build_migration_16())
+ migrator.register_migration(build_migration_17())
+ migrator.register_migration(build_migration_18())
+ migrator.register_migration(build_migration_19(app_config=config))
+ migrator.register_migration(build_migration_20())
+ migrator.register_migration(build_migration_21())
+ migrator.register_migration(build_migration_22(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_23(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_24(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_25(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_26(app_config=config, logger=logger))
+ migrator.register_migration(build_migration_27())
+ migrator.register_migration(build_migration_28())
+ migrator.register_migration(build_migration_29())
+ migrator.register_migration(build_migration_30())
+ migrator.register_migration(build_migration_31())
+ migrator.register_migration(build_migration_32())
+ migrator.register_migration(build_migration_33())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py
index f66374e0b1e..17e61334f09 100644
--- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py
@@ -35,7 +35,7 @@ def __call__(self, cursor: sqlite3.Cursor) -> None:
def _remove_convert_cache(self) -> None:
"""Rename models/.cache to models/.convert_cache."""
- self._logger.info("Removing .cache directory. Converted models will now be cached in .convert_cache.")
+ self._logger.info("Removing models/.cache directory. Converted models will now be cached in .convert_cache.")
legacy_convert_path = self._app_config.root_path / "models" / ".cache"
shutil.rmtree(legacy_convert_path, ignore_errors=True)
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_12.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_12.py
new file mode 100644
index 00000000000..f81632445c8
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_12.py
@@ -0,0 +1,35 @@
+import shutil
+import sqlite3
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration12Callback:
+ def __init__(self, app_config: InvokeAIAppConfig) -> None:
+ self._app_config = app_config
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._remove_model_convert_cache_dir()
+
+ def _remove_model_convert_cache_dir(self) -> None:
+ """
+ Removes unused model convert cache directory
+ """
+ convert_cache = self._app_config.convert_cache_path
+ shutil.rmtree(convert_cache, ignore_errors=True)
+
+
+def build_migration_12(app_config: InvokeAIAppConfig) -> Migration:
+ """
+ Build the migration from database version 11 to 12.
+
+ This migration removes the now-unused model convert cache directory.
+ """
+ migration_12 = Migration(
+ from_version=11,
+ to_version=12,
+ callback=Migration12Callback(app_config),
+ )
+
+ return migration_12
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_13.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_13.py
new file mode 100644
index 00000000000..401c0a4866a
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_13.py
@@ -0,0 +1,31 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration13Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._add_archived_col(cursor)
+
+ def _add_archived_col(self, cursor: sqlite3.Cursor) -> None:
+ """
+ - Adds `archived` columns to the board table.
+ """
+
+ cursor.execute("ALTER TABLE boards ADD COLUMN archived BOOLEAN DEFAULT FALSE;")
+
+
+def build_migration_13() -> Migration:
+ """
+ Build the migration from database version 12 to 13..
+
+ This migration does the following:
+ - Adds `archived` columns to the board table.
+ """
+ migration_13 = Migration(
+ from_version=12,
+ to_version=13,
+ callback=Migration13Callback(),
+ )
+
+ return migration_13
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py
new file mode 100644
index 00000000000..399f5a71d20
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py
@@ -0,0 +1,61 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration14Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._create_style_presets(cursor)
+
+ def _create_style_presets(self, cursor: sqlite3.Cursor) -> None:
+ """Create the table used to store style presets."""
+ tables = [
+ """--sql
+ CREATE TABLE IF NOT EXISTS style_presets (
+ id TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL,
+ preset_data TEXT NOT NULL,
+ type TEXT NOT NULL DEFAULT "user",
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
+ );
+ """
+ ]
+
+ # Add trigger for `updated_at`.
+ triggers = [
+ """--sql
+ CREATE TRIGGER IF NOT EXISTS style_presets
+ AFTER UPDATE
+ ON style_presets FOR EACH ROW
+ BEGIN
+ UPDATE style_presets SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE id = old.id;
+ END;
+ """
+ ]
+
+ # Add indexes for searchable fields
+ indices = [
+ "CREATE INDEX IF NOT EXISTS idx_style_presets_name ON style_presets(name);",
+ ]
+
+ for stmt in tables + indices + triggers:
+ cursor.execute(stmt)
+
+
+def build_migration_14() -> Migration:
+ """
+ Build the migration from database version 13 to 14..
+
+ This migration does the following:
+ - Create the table used to store style presets.
+ """
+ migration_14 = Migration(
+ from_version=13,
+ to_version=14,
+ callback=Migration14Callback(),
+ )
+
+ return migration_14
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_15.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_15.py
new file mode 100644
index 00000000000..455ff71ab5b
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_15.py
@@ -0,0 +1,34 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration15Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._add_origin_col(cursor)
+
+ def _add_origin_col(self, cursor: sqlite3.Cursor) -> None:
+ """
+ - Adds `origin` column to the session queue table.
+ - Adds `destination` column to the session queue table.
+ """
+
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN origin TEXT;")
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN destination TEXT;")
+
+
+def build_migration_15() -> Migration:
+ """
+ Build the migration from database version 14 to 15.
+
+ This migration does the following:
+ - Adds `origin` column to the session queue table.
+ - Adds `destination` column to the session queue table.
+ """
+ migration_15 = Migration(
+ from_version=14,
+ to_version=15,
+ callback=Migration15Callback(),
+ )
+
+ return migration_15
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_16.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_16.py
new file mode 100644
index 00000000000..d401247b923
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_16.py
@@ -0,0 +1,31 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration16Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._add_retried_from_item_id_col(cursor)
+
+ def _add_retried_from_item_id_col(self, cursor: sqlite3.Cursor) -> None:
+ """
+ - Adds `retried_from_item_id` column to the session queue table.
+ """
+
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN retried_from_item_id INTEGER;")
+
+
+def build_migration_16() -> Migration:
+ """
+ Build the migration from database version 15 to 16.
+
+ This migration does the following:
+ - Adds `retried_from_item_id` column to the session queue table.
+ """
+ migration_16 = Migration(
+ from_version=15,
+ to_version=16,
+ callback=Migration16Callback(),
+ )
+
+ return migration_16
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_17.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_17.py
new file mode 100644
index 00000000000..8e2e788a8f5
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_17.py
@@ -0,0 +1,35 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration17Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._add_workflows_tags_col(cursor)
+
+ def _add_workflows_tags_col(self, cursor: sqlite3.Cursor) -> None:
+ """
+ - Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
+ workflow JSON.
+ """
+
+ cursor.execute(
+ "ALTER TABLE workflow_library ADD COLUMN tags TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.tags')) VIRTUAL;"
+ )
+
+
+def build_migration_17() -> Migration:
+ """
+ Build the migration from database version 16 to 17.
+
+ This migration does the following:
+ - Adds `tags` column to the workflow_library table. It is a generated column that extracts the tags from the
+ workflow JSON.
+ """
+ migration_17 = Migration(
+ from_version=16,
+ to_version=17,
+ callback=Migration17Callback(),
+ )
+
+ return migration_17
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
new file mode 100644
index 00000000000..7879ddc378f
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_18.py
@@ -0,0 +1,47 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration18Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._make_workflow_opened_at_nullable(cursor)
+
+ def _make_workflow_opened_at_nullable(self, cursor: sqlite3.Cursor) -> None:
+ """
+ Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
+ - Dropping the existing `opened_at` column
+ - Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
+ """
+ # For index renaming in SQLite, we need to drop and recreate
+ cursor.execute("DROP INDEX IF EXISTS idx_workflow_library_opened_at;")
+ # Rename existing column to deprecated
+ cursor.execute("ALTER TABLE workflow_library DROP COLUMN opened_at;")
+ # Add new nullable column - all values will be NULL - no migration of data needed
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN opened_at DATETIME;")
+ # Create new index on the new column
+ cursor.execute(
+ "CREATE INDEX idx_workflow_library_opened_at ON workflow_library(opened_at);",
+ )
+
+
+def build_migration_18() -> Migration:
+ """
+ Build the migration from database version 17 to 18.
+
+ This migration does the following:
+ - Make the `opened_at` column nullable in the `workflow_library` table. This is accomplished by:
+ - Dropping the existing `idx_workflow_library_opened_at` index (must be done before dropping the column)
+ - Dropping the existing `opened_at` column
+ - Adding a new nullable column `opened_at` (no data migration needed, all values will be NULL)
+ - Adding a new `idx_workflow_library_opened_at` index on the `opened_at` column
+ """
+ migration_18 = Migration(
+ from_version=17,
+ to_version=18,
+ callback=Migration18Callback(),
+ )
+
+ return migration_18
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_19.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_19.py
new file mode 100644
index 00000000000..363cd2e8d83
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_19.py
@@ -0,0 +1,37 @@
+import sqlite3
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
+
+
+class Migration19Callback:
+ def __init__(self, app_config: InvokeAIAppConfig):
+ self.models_path = app_config.models_path
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._populate_size(cursor)
+ self._add_size_column(cursor)
+
+ def _add_size_column(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute(
+ "ALTER TABLE models ADD COLUMN file_size INTEGER "
+ "GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL"
+ )
+
+ def _populate_size(self, cursor: sqlite3.Cursor) -> None:
+ all_models = cursor.execute("SELECT id, path FROM models;").fetchall()
+
+ for model_id, model_path in all_models:
+ mod = ModelOnDisk(self.models_path / model_path)
+ cursor.execute(
+ "UPDATE models SET config = json_set(config, '$.file_size', ?) WHERE id = ?", (mod.size(), model_id)
+ )
+
+
+def build_migration_19(app_config: InvokeAIAppConfig) -> Migration:
+ return Migration(
+ from_version=18,
+ to_version=19,
+ callback=Migration19Callback(app_config),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_20.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_20.py
new file mode 100644
index 00000000000..420b3835705
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_20.py
@@ -0,0 +1,37 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration20Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute(
+ """
+ -- many-to-many relationship table for models
+ CREATE TABLE IF NOT EXISTS model_relationships (
+ -- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
+ model_key_1 TEXT NOT NULL,
+ model_key_2 TEXT NOT NULL,
+ created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ PRIMARY KEY (model_key_1, model_key_2),
+ -- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
+ FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
+ FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
+ );
+ """
+ )
+ cursor.execute(
+ """
+ -- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
+ CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
+ ON model_relationships(model_key_2)
+ """
+ )
+
+
+def build_migration_20() -> Migration:
+ return Migration(
+ from_version=19,
+ to_version=20,
+ callback=Migration20Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_21.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_21.py
new file mode 100644
index 00000000000..82f63772c7c
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_21.py
@@ -0,0 +1,40 @@
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration21Callback:
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute(
+ """
+ CREATE TABLE client_state (
+ id INTEGER PRIMARY KEY CHECK(id = 1),
+ data TEXT NOT NULL, -- Frontend will handle the shape of this data
+ updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP)
+ );
+ """
+ )
+ cursor.execute(
+ """
+ CREATE TRIGGER tg_client_state_updated_at
+ AFTER UPDATE ON client_state
+ FOR EACH ROW
+ BEGIN
+ UPDATE client_state
+ SET updated_at = CURRENT_TIMESTAMP
+ WHERE id = OLD.id;
+ END;
+ """
+ )
+
+
+def build_migration_21() -> Migration:
+ """Builds the migration object for migrating from version 20 to version 21. This includes:
+ - Creating the `client_state` table.
+ - Adding a trigger to update the `updated_at` field on updates.
+ """
+ return Migration(
+ from_version=20,
+ to_version=21,
+ callback=Migration21Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_22.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_22.py
new file mode 100644
index 00000000000..bf97cbd00ac
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_22.py
@@ -0,0 +1,89 @@
+import sqlite3
+from logging import Logger
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration22Callback:
+ def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
+ self._app_config = app_config
+ self._logger = logger
+ self._models_dir = app_config.models_path.resolve()
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._logger.info("Removing UNIQUE(name, base, type) constraint from models table")
+
+ # Step 1: Rename the existing models table
+ cursor.execute("ALTER TABLE models RENAME TO models_old;")
+
+ # Step 2: Create the new models table without the UNIQUE(name, base, type) constraint
+ cursor.execute(
+ """--sql
+ CREATE TABLE models (
+ id TEXT NOT NULL PRIMARY KEY,
+ hash TEXT GENERATED ALWAYS as (json_extract(config, '$.hash')) VIRTUAL NOT NULL,
+ base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL,
+ type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL,
+ path TEXT GENERATED ALWAYS as (json_extract(config, '$.path')) VIRTUAL NOT NULL,
+ format TEXT GENERATED ALWAYS as (json_extract(config, '$.format')) VIRTUAL NOT NULL,
+ name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) VIRTUAL NOT NULL,
+ description TEXT GENERATED ALWAYS as (json_extract(config, '$.description')) VIRTUAL,
+ source TEXT GENERATED ALWAYS as (json_extract(config, '$.source')) VIRTUAL NOT NULL,
+ source_type TEXT GENERATED ALWAYS as (json_extract(config, '$.source_type')) VIRTUAL NOT NULL,
+ source_api_response TEXT GENERATED ALWAYS as (json_extract(config, '$.source_api_response')) VIRTUAL,
+ trigger_phrases TEXT GENERATED ALWAYS as (json_extract(config, '$.trigger_phrases')) VIRTUAL,
+ file_size INTEGER GENERATED ALWAYS as (json_extract(config, '$.file_size')) VIRTUAL NOT NULL,
+ -- Serialized JSON representation of the whole config object, which will contain additional fields from subclasses
+ config TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Updated via trigger
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ -- Explicit unique constraint on path
+ UNIQUE(path)
+ );
+ """
+ )
+
+ # Step 3: Copy all data from the old table to the new table
+ # Only copy the stored columns (id, config, created_at, updated_at), not the virtual columns
+ cursor.execute(
+ "INSERT INTO models (id, config, created_at, updated_at) "
+ "SELECT id, config, created_at, updated_at FROM models_old;"
+ )
+
+ # Step 4: Drop the old table
+ cursor.execute("DROP TABLE models_old;")
+
+ # Step 5: Recreate indexes
+ cursor.execute("CREATE INDEX IF NOT EXISTS base_index ON models(base);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS type_index ON models(type);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS name_index ON models(name);")
+
+ # Step 6: Recreate the updated_at trigger
+ cursor.execute(
+ """--sql
+ CREATE TRIGGER models_updated_at
+ AFTER UPDATE
+ ON models FOR EACH ROW
+ BEGIN
+ UPDATE models SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE id = old.id;
+ END;
+ """
+ )
+
+
+def build_migration_22(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
+ """Builds the migration object for migrating from version 21 to version 22.
+
+ This migration:
+ - Removes the UNIQUE constraint on the combination of (base, name, type) columns in the models table
+ - Adds an explicit UNIQUE contraint on the path column
+ """
+
+ return Migration(
+ from_version=21,
+ to_version=22,
+ callback=Migration22Callback(app_config=app_config, logger=logger),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_23.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_23.py
new file mode 100644
index 00000000000..3b5dc467b38
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_23.py
@@ -0,0 +1,193 @@
+import json
+import sqlite3
+from copy import deepcopy
+from logging import Logger
+from typing import Any
+
+from pydantic import ValidationError
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+from invokeai.backend.model_manager.configs.factory import AnyModelConfig, AnyModelConfigValidator
+from invokeai.backend.model_manager.configs.unknown import Unknown_Config
+from invokeai.backend.model_manager.taxonomy import (
+ BaseModelType,
+ ClipVariantType,
+ FluxVariantType,
+ ModelFormat,
+ ModelType,
+ ModelVariantType,
+ SchedulerPredictionType,
+)
+
+
+class Migration23Callback:
+ def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
+ self._app_config = app_config
+ self._logger = logger
+ self._models_dir = app_config.models_path.resolve()
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ # Grab all model records
+ cursor.execute("SELECT id, config FROM models;")
+ rows = cursor.fetchall()
+
+ migrated_count = 0
+ fallback_count = 0
+
+ for model_id, config_json in rows:
+ try:
+ # Migrate the config JSON to the latest schema
+ config_dict: dict[str, Any] = json.loads(config_json)
+ migrated_config = self._parse_and_migrate_config(config_dict)
+
+ if isinstance(migrated_config, Unknown_Config):
+ fallback_count += 1
+ else:
+ migrated_count += 1
+
+ # Write the migrated config back to the database
+ cursor.execute(
+ "UPDATE models SET config = ? WHERE id = ?;",
+ (migrated_config.model_dump_json(), model_id),
+ )
+ except ValidationError as e:
+ self._logger.error("Invalid config schema for model %s: %s", model_id, e)
+ raise
+ except json.JSONDecodeError as e:
+ self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
+ raise
+
+ if migrated_count > 0 and fallback_count == 0:
+ self._logger.info(f"Migration complete: {migrated_count} model configs migrated")
+ elif migrated_count > 0 and fallback_count > 0:
+ self._logger.warning(
+ f"Migration complete: {migrated_count} model configs migrated, "
+ f"{fallback_count} model configs could not be migrated and were saved as unknown models",
+ )
+ elif migrated_count == 0 and fallback_count > 0:
+ self._logger.warning(
+ f"Migration complete: all {fallback_count} model configs could not be migrated and were saved as unknown models",
+ )
+ else:
+ self._logger.info("Migration complete: no model configs needed migration")
+
+ def _parse_and_migrate_config(self, config_dict: dict[str, Any]) -> AnyModelConfig:
+ # In v6.9.0 we made some improvements to the model taxonomy and the model config schemas. There are a changes
+ # we need to make to old configs to bring them up to date.
+
+ type = config_dict.get("type")
+ format = config_dict.get("format")
+ base = config_dict.get("base")
+
+ if base == BaseModelType.Flux.value and type == ModelType.Main.value:
+ # Prior to v6.9.0, we used an awkward combination of `config_path` and `variant` to distinguish between FLUX
+ # variants.
+ #
+ # `config_path` was set to one of:
+ # - flux-dev
+ # - flux-dev-fill
+ # - flux-schnell
+ #
+ # `variant` was set to ModelVariantType.Inpaint for FLUX Fill models and ModelVariantType.Normal for all other FLUX
+ # models.
+ #
+ # We now use the `variant` field to directly represent the FLUX variant type, and `config_path` is no longer used.
+
+ # Extract and remove `config_path` if present.
+ config_path = config_dict.pop("config_path", None)
+
+ match config_path:
+ case "flux-dev":
+ config_dict["variant"] = FluxVariantType.Dev.value
+ case "flux-dev-fill":
+ config_dict["variant"] = FluxVariantType.DevFill.value
+ case "flux-schnell":
+ config_dict["variant"] = FluxVariantType.Schnell.value
+ case _:
+ # Unknown config_path - default to Dev variant
+ config_dict["variant"] = FluxVariantType.Dev.value
+
+ if (
+ base
+ in {
+ BaseModelType.StableDiffusion1.value,
+ BaseModelType.StableDiffusion2.value,
+ BaseModelType.StableDiffusionXL.value,
+ BaseModelType.StableDiffusionXLRefiner.value,
+ }
+ and type == ModelType.Main.value
+ ):
+ # Prior to v6.9.0, the prediction_type field was optional and would default to Epsilon if not present.
+ # We now make it explicit and always present. Use the existing value if present, otherwise default to
+ # Epsilon, matching the probe logic.
+ #
+ # It's only on SD1.x, SD2.x, and SDXL main models.
+ config_dict["prediction_type"] = config_dict.get("prediction_type", SchedulerPredictionType.Epsilon.value)
+
+ # Prior to v6.9.0, the variant field was optional and would default to Normal if not present.
+ # We now make it explicit and always present. Use the existing value if present, otherwise default to
+ # Normal. It's only on SD main models.
+ config_dict["variant"] = config_dict.get("variant", ModelVariantType.Normal.value)
+
+ if base == BaseModelType.Flux.value and type == ModelType.LoRA.value and format == ModelFormat.Diffusers.value:
+ # Prior to v6.9.0, we used the Diffusers format for FLUX LoRA models that used the diffusers _key_
+ # structure. This was misleading, as everywhere else in the application, we used the Diffusers format
+ # to indicate that the model files were in the Diffusers _file_ format (i.e. a directory containing
+ # the weights and config files).
+ #
+ # At runtime, we check the LoRA's state dict directly to determine the key structure, so we do not need
+ # to rely on the format field for this purpose. As of v6.9.0, we always use the LyCORIS format for single-
+ # file LoRAs, regardless of the key structure.
+ #
+ # This change allows LoRA model identification to not need a special case for FLUX LoRAs in the diffusers
+ # key format.
+ config_dict["format"] = ModelFormat.LyCORIS.value
+
+ if type == ModelType.CLIPVision.value:
+ # Prior to v6.9.0, some CLIP Vision models were associated with a specific base model architecture:
+ # - CLIP-ViT-bigG-14-laion2B-39B-b160k is the image encoder for SDXL IP Adapter and was associated with SDXL
+ # - CLIP-ViT-H-14-laion2B-s32B-b79K is the image encoder for SD1.5 IP Adapter and was associated with SD1.5
+ #
+ # While this made some sense at the time, it is more correct and flexible to treat CLIP Vision models
+ # as independent of any specific base model architecture.
+ config_dict["base"] = BaseModelType.Any.value
+
+ if type == ModelType.CLIPEmbed.value:
+ # Prior to v6.9.0, some CLIP Embed models did not have a variant set. The default was the L variant.
+ # We now make it explicit and always present. Use the existing value if present, otherwise default to
+ # L variant. Also, treat CLIP Embed models as independent of any specific base model architecture.
+ config_dict["base"] = BaseModelType.Any.value
+ config_dict["variant"] = config_dict.get("variant", ClipVariantType.L.value)
+
+ try:
+ migrated_config = AnyModelConfigValidator.validate_python(config_dict)
+ # This could be a ValidationError or any other error that occurs during validation. A failure to generate a
+ # union discriminator could raise a ValueError, for example. Who knows what else could fail - catch all.
+ except Exception as e:
+ self._logger.error("Failed to validate migrated config, attempting to save as unknown model: %s", e)
+ cloned_config_dict = deepcopy(config_dict)
+ cloned_config_dict.pop("base", None)
+ cloned_config_dict.pop("type", None)
+ cloned_config_dict.pop("format", None)
+
+ migrated_config = Unknown_Config(
+ **cloned_config_dict,
+ base=BaseModelType.Unknown,
+ type=ModelType.Unknown,
+ format=ModelFormat.Unknown,
+ )
+ return migrated_config
+
+
+def build_migration_23(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
+ """Builds the migration object for migrating from version 22 to version 23.
+
+ This migration updates model configurations to the latest config schemas for v6.9.0.
+ """
+
+ return Migration(
+ from_version=22,
+ to_version=23,
+ callback=Migration23Callback(app_config=app_config, logger=logger),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_24.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_24.py
new file mode 100644
index 00000000000..5ae8563b3e6
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_24.py
@@ -0,0 +1,240 @@
+import json
+import sqlite3
+from logging import Logger
+from pathlib import Path
+from typing import NamedTuple
+
+from pydantic import ValidationError
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+from invokeai.backend.model_manager.configs.factory import AnyModelConfigValidator
+
+
+class NormalizeResult(NamedTuple):
+ new_relative_path: str | None
+ rollback_ops: list[tuple[Path, Path]]
+
+
+class Migration24Callback:
+ def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
+ self._app_config = app_config
+ self._logger = logger
+ self._models_dir = app_config.models_path.resolve()
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ # Grab all model records
+ cursor.execute("SELECT id, config FROM models;")
+ rows = cursor.fetchall()
+
+ for model_id, config_json in rows:
+ try:
+ config = AnyModelConfigValidator.validate_json(config_json)
+ except ValidationError:
+ # This could happen if the config schema changed in a way that makes old configs invalid. Unlikely
+ # for users, more likely for devs testing out migration paths.
+ self._logger.warning("Skipping model %s: invalid config schema", model_id)
+ continue
+ except json.JSONDecodeError:
+ # This should never happen, as we use pydantic to serialize the config to JSON.
+ self._logger.warning("Skipping model %s: invalid config JSON", model_id)
+ continue
+
+ # We'll use a savepoint so we can roll back the database update if something goes wrong, and a simple
+ # rollback of file operations if needed.
+ cursor.execute("SAVEPOINT migrate_model")
+ try:
+ new_relative_path, rollback_ops = self._normalize_model_storage(
+ key=config.key,
+ path_value=config.path,
+ )
+ except Exception as err:
+ self._logger.error("Error normalizing model %s: %s", config.key, err)
+ cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
+ cursor.execute("RELEASE SAVEPOINT migrate_model")
+ continue
+
+ if new_relative_path is None:
+ cursor.execute("RELEASE SAVEPOINT migrate_model")
+ continue
+
+ config.path = new_relative_path
+ try:
+ cursor.execute(
+ "UPDATE models SET config = ? WHERE id = ?;",
+ (config.model_dump_json(), model_id),
+ )
+ except Exception as err:
+ self._logger.error("Database update failed for model %s: %s", config.key, err)
+ cursor.execute("ROLLBACK TO SAVEPOINT migrate_model")
+ cursor.execute("RELEASE SAVEPOINT migrate_model")
+ self._rollback_file_ops(rollback_ops)
+ continue
+
+ cursor.execute("RELEASE SAVEPOINT migrate_model")
+
+ self._prune_empty_directories()
+
+ def _normalize_model_storage(self, key: str, path_value: str) -> NormalizeResult:
+ models_dir = self._models_dir
+ stored_path = Path(path_value)
+
+ relative_path: Path | None
+ if stored_path.is_absolute():
+ # If the stored path is absolute, we need to check if it's inside the models directory, which means it is
+ # an Invoke-managed model. If it's outside, it is user-managed we leave it alone.
+ try:
+ relative_path = stored_path.resolve().relative_to(models_dir)
+ except ValueError:
+ self._logger.info("Leaving user-managed model %s at %s", key, stored_path)
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+ else:
+ # Relative paths are always relative to the models directory and thus Invoke-managed.
+ relative_path = stored_path
+
+ # If the relative path is empty, assume something is wrong. Warn and skip.
+ if not relative_path.parts:
+ self._logger.warning("Skipping model %s: empty relative path", key)
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+
+ # Sanity check: the path is relative. It should be present in the models directory.
+ absolute_path = (models_dir / relative_path).resolve()
+ if not absolute_path.exists():
+ self._logger.warning(
+ "Skipping model %s: expected model files at %s but nothing was found",
+ key,
+ absolute_path,
+ )
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+
+ if relative_path.parts[0] == key:
+ # Already normalized. Still ensure the stored path is relative.
+ normalized_path = relative_path.as_posix()
+ # If the stored path is already the normalized path, no change is needed.
+ new_relative_path = normalized_path if stored_path.as_posix() != normalized_path else None
+ return NormalizeResult(new_relative_path=new_relative_path, rollback_ops=[])
+
+ # We'll store the file operations we perform so we can roll them back if needed.
+ rollback_ops: list[tuple[Path, Path]] = []
+
+ # Destination directory is models_dir/ - a flat directory structure.
+ destination_dir = models_dir / key
+
+ try:
+ if absolute_path.is_file():
+ destination_dir.mkdir(parents=True, exist_ok=True)
+ dest_file = destination_dir / absolute_path.name
+ # This really shouldn't happen.
+ if dest_file.exists():
+ self._logger.warning(
+ "Destination for model %s already exists at %s; skipping move",
+ key,
+ dest_file,
+ )
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+
+ self._logger.info("Moving model file %s -> %s", absolute_path, dest_file)
+
+ # `Path.rename()` effectively moves the file or directory.
+ absolute_path.rename(dest_file)
+ rollback_ops.append((dest_file, absolute_path))
+
+ return NormalizeResult(
+ new_relative_path=(Path(key) / dest_file.name).as_posix(),
+ rollback_ops=rollback_ops,
+ )
+
+ if absolute_path.is_dir():
+ dest_path = destination_dir
+ # This really shouldn't happen.
+ if dest_path.exists():
+ self._logger.warning(
+ "Destination directory %s already exists for model %s; skipping",
+ dest_path,
+ key,
+ )
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+
+ self._logger.info("Moving model directory %s -> %s", absolute_path, dest_path)
+
+ # `Path.rename()` effectively moves the file or directory.
+ absolute_path.rename(dest_path)
+ rollback_ops.append((dest_path, absolute_path))
+
+ return NormalizeResult(
+ new_relative_path=Path(key).as_posix(),
+ rollback_ops=rollback_ops,
+ )
+
+ # Maybe a broken symlink or something else weird?
+ self._logger.warning("Skipping model %s: path %s is neither a file nor directory", key, absolute_path)
+ return NormalizeResult(new_relative_path=None, rollback_ops=[])
+ except Exception:
+ self._rollback_file_ops(rollback_ops)
+ raise
+
+ def _rollback_file_ops(self, rollback_ops: list[tuple[Path, Path]]) -> None:
+ # This is a super-simple rollback that just reverses the move operations we performed.
+ for source, destination in reversed(rollback_ops):
+ try:
+ if source.exists():
+ source.rename(destination)
+ except Exception as err:
+ self._logger.error("Failed to rollback move %s -> %s: %s", source, destination, err)
+
+ def _prune_empty_directories(self) -> None:
+ # These directories are system directories we want to keep even if empty. Technically, the app should not
+ # have any problems if these are removed, creating them as needed, but it's cleaner to just leave them alone.
+ keep_names = {"model_images", ".download_cache"}
+ keep_dirs = {self._models_dir / name for name in keep_names}
+ removed_dirs: set[Path] = set()
+
+ # Walk the models directory tree from the bottom up, removing empty directories. We sort by path length
+ # descending to ensure we visit children before parents.
+ for directory in sorted(self._models_dir.rglob("*"), key=lambda p: len(p.parts), reverse=True):
+ if not directory.is_dir():
+ continue
+ if directory == self._models_dir:
+ continue
+ if any(directory == keep or keep in directory.parents for keep in keep_dirs):
+ continue
+
+ try:
+ next(directory.iterdir())
+ except StopIteration:
+ try:
+ directory.rmdir()
+ removed_dirs.add(directory)
+ self._logger.debug("Removed empty directory %s", directory)
+ except OSError:
+ # Directory not empty (or some other error) - bail out.
+ self._logger.warning("Failed to prune directory %s - not empty?", directory)
+ continue
+ except OSError:
+ continue
+
+ self._logger.info("Pruned %d empty directories under %s", len(removed_dirs), self._models_dir)
+
+
+def build_migration_24(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
+ """Builds the migration object for migrating from version 23 to version 24.
+
+ This migration normalizes on-disk model storage so that each model lives within
+ a directory named by its key inside the Invoke-managed models directory, and
+ updates database records to reference the new relative paths.
+
+ This migration behaves a bit differently than others. Because it involves FS operations, if we rolled the
+ DB back on any failure, we could leave the FS out of sync with the DB. Instead, we use savepoints
+ to roll back individual model updates on failure, and we roll back any FS operations we performed
+ for that model.
+
+ If a model cannot be migrated for any reason (invalid config, missing files, FS errors, DB errors), we log a
+ warning and skip it, leaving it in its original state and location. The model will still work, but it will be in
+ the "wrong" location on disk.
+ """
+
+ return Migration(
+ from_version=23,
+ to_version=24,
+ callback=Migration24Callback(app_config=app_config, logger=logger),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py
new file mode 100644
index 00000000000..0ce8a8ff6a5
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_25.py
@@ -0,0 +1,61 @@
+import json
+import sqlite3
+from logging import Logger
+from typing import Any
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+from invokeai.backend.model_manager.taxonomy import ModelType, Qwen3VariantType
+
+
+class Migration25Callback:
+ def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
+ self._app_config = app_config
+ self._logger = logger
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT id, config FROM models;")
+ rows = cursor.fetchall()
+
+ migrated_count = 0
+
+ for model_id, config_json in rows:
+ try:
+ config_dict: dict[str, Any] = json.loads(config_json)
+
+ if config_dict.get("type") != ModelType.Qwen3Encoder.value:
+ continue
+
+ if "variant" in config_dict:
+ continue
+
+ config_dict["variant"] = Qwen3VariantType.Qwen3_4B.value
+
+ cursor.execute(
+ "UPDATE models SET config = ? WHERE id = ?;",
+ (json.dumps(config_dict), model_id),
+ )
+ migrated_count += 1
+
+ except json.JSONDecodeError as e:
+ self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
+ raise
+
+ if migrated_count > 0:
+ self._logger.info(f"Migration complete: {migrated_count} Qwen3 encoder configs updated with variant field")
+ else:
+ self._logger.info("Migration complete: no Qwen3 encoder configs needed migration")
+
+
+def build_migration_25(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
+ """Builds the migration object for migrating from version 24 to version 25.
+
+ This migration adds the variant field to existing Qwen3 encoder models.
+ Models installed before the variant field was added will default to Qwen3_4B (for Z-Image compatibility).
+ """
+
+ return Migration(
+ from_version=24,
+ to_version=25,
+ callback=Migration25Callback(app_config=app_config, logger=logger),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_26.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_26.py
new file mode 100644
index 00000000000..d392d284139
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_26.py
@@ -0,0 +1,115 @@
+import json
+import sqlite3
+from logging import Logger
+from pathlib import Path
+from typing import Any
+
+from invokeai.app.services.config import InvokeAIAppConfig
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, ZImageVariantType
+
+
+class Migration26Callback:
+ def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
+ self._app_config = app_config
+ self._logger = logger
+
+ def _detect_variant_from_scheduler(self, model_path: Path) -> ZImageVariantType:
+ """Detect Z-Image variant from scheduler config for Diffusers models.
+
+ Z-Image variants are distinguished by the scheduler shift value:
+ - Turbo (distilled): shift = 3.0
+ - Base (undistilled): shift = 6.0
+ """
+ scheduler_config_path = model_path / "scheduler" / "scheduler_config.json"
+
+ if not scheduler_config_path.exists():
+ return ZImageVariantType.Turbo
+
+ try:
+ with open(scheduler_config_path, "r", encoding="utf-8") as f:
+ scheduler_config = json.load(f)
+
+ shift = scheduler_config.get("shift", 3.0)
+
+ # ZBase (undistilled) uses shift = 6.0, Turbo uses shift = 3.0
+ if shift >= 5.0:
+ return ZImageVariantType.ZBase
+ else:
+ return ZImageVariantType.Turbo
+ except (json.JSONDecodeError, OSError) as e:
+ self._logger.warning(f"Could not read scheduler config: {e}, defaulting to Turbo")
+ return ZImageVariantType.Turbo
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT id, config FROM models;")
+ rows = cursor.fetchall()
+
+ migrated_turbo = 0
+ migrated_base = 0
+
+ for model_id, config_json in rows:
+ try:
+ config_dict: dict[str, Any] = json.loads(config_json)
+
+ # Only migrate Z-Image main models
+ if config_dict.get("base") != BaseModelType.ZImage.value:
+ continue
+
+ if config_dict.get("type") != ModelType.Main.value:
+ continue
+
+ # Skip if variant already set
+ if "variant" in config_dict:
+ continue
+
+ # Determine variant based on format
+ model_format = config_dict.get("format")
+ model_path = config_dict.get("path")
+
+ if model_format == ModelFormat.Diffusers.value and model_path:
+ # For Diffusers models, detect from scheduler config
+ variant = self._detect_variant_from_scheduler(Path(model_path))
+ else:
+ # For Checkpoint/GGUF, default to Turbo (Base only available as Diffusers)
+ variant = ZImageVariantType.Turbo
+
+ config_dict["variant"] = variant.value
+
+ cursor.execute(
+ "UPDATE models SET config = ? WHERE id = ?;",
+ (json.dumps(config_dict), model_id),
+ )
+
+ if variant == ZImageVariantType.ZBase:
+ migrated_base += 1
+ else:
+ migrated_turbo += 1
+
+ except json.JSONDecodeError as e:
+ self._logger.error("Invalid config JSON for model %s: %s", model_id, e)
+ raise
+
+ total = migrated_turbo + migrated_base
+ if total > 0:
+ self._logger.info(
+ f"Migration complete: {total} Z-Image model configs updated "
+ f"({migrated_turbo} Turbo, {migrated_base} Base)"
+ )
+ else:
+ self._logger.info("Migration complete: no Z-Image model configs needed migration")
+
+
+def build_migration_26(app_config: InvokeAIAppConfig, logger: Logger) -> Migration:
+ """Builds the migration object for migrating from version 25 to version 26.
+
+ This migration adds the variant field to existing Z-Image main models.
+ Models installed before the variant field was added will default to Turbo
+ (the only variant available before Z-Image Base support was added).
+ """
+
+ return Migration(
+ from_version=25,
+ to_version=26,
+ callback=Migration26Callback(app_config=app_config, logger=logger),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py
new file mode 100644
index 00000000000..b80ea073ef8
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py
@@ -0,0 +1,366 @@
+"""Migration 27: Add multi-user support, per-user client state, and app settings.
+
+This migration adds the database schema for multi-user support, including:
+- users table for user accounts
+- user_sessions table for session management
+- user_invitations table for invitation system
+- shared_boards table for board sharing
+- Adding user_id columns to existing tables for data ownership
+- Restructuring client_state table to support per-user storage
+- app_settings table for storing JWT secret and other app-level settings
+"""
+
+import json
+import secrets
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration27Callback:
+ """Migration to add multi-user support, per-user client state, and app settings."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._create_users_table(cursor)
+ self._create_user_sessions_table(cursor)
+ self._create_user_invitations_table(cursor)
+ self._create_shared_boards_table(cursor)
+ self._update_boards_table(cursor)
+ self._update_images_table(cursor)
+ self._update_workflows_table(cursor)
+ self._update_session_queue_table(cursor)
+ self._update_style_presets_table(cursor)
+ self._create_system_user(cursor)
+ self._update_client_state_table(cursor)
+ self._create_app_settings_table(cursor)
+ self._generate_jwt_secret(cursor)
+
+ def _create_users_table(self, cursor: sqlite3.Cursor) -> None:
+ """Create users table."""
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ user_id TEXT NOT NULL PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ password_hash TEXT NOT NULL,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ last_login_at DATETIME
+ );
+ """)
+
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);")
+
+ cursor.execute("""
+ CREATE TRIGGER IF NOT EXISTS tg_users_updated_at
+ AFTER UPDATE ON users FOR EACH ROW
+ BEGIN
+ UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE user_id = old.user_id;
+ END;
+ """)
+
+ def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None:
+ """Create user_sessions table for session management."""
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS user_sessions (
+ session_id TEXT NOT NULL PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ token_hash TEXT NOT NULL,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ );
+ """)
+
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);")
+
+ def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None:
+ """Create user_invitations table for invitation system."""
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS user_invitations (
+ invitation_id TEXT NOT NULL PRIMARY KEY,
+ email TEXT NOT NULL,
+ invited_by TEXT NOT NULL,
+ invitation_code TEXT NOT NULL UNIQUE,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE
+ );
+ """)
+
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);")
+ cursor.execute(
+ "CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);"
+ )
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);")
+
+ def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None:
+ """Create shared_boards table for board sharing."""
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS shared_boards (
+ board_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ can_edit BOOLEAN NOT NULL DEFAULT FALSE,
+ shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ PRIMARY KEY (board_id, user_id),
+ FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ );
+ """)
+
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);")
+
+ def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to boards table."""
+ # Check if boards table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
+ if cursor.fetchone() is None:
+ return
+
+ # Check if user_id column exists
+ cursor.execute("PRAGMA table_info(boards);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);")
+
+ def _update_images_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id column to images table."""
+ # Check if images table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(images);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);")
+
+ def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to workflows table."""
+ # Check if workflows table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(workflows);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);")
+
+ def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id column to session_queue table."""
+ # Check if session_queue table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(session_queue);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);")
+
+ def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to style_presets table."""
+ # Check if style_presets table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(style_presets);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);")
+
+ def _create_system_user(self, cursor: sqlite3.Cursor) -> None:
+ """Create system user for backward compatibility.
+
+ The system user is NOT an admin - it's just used to own existing data
+ from before multi-user support was added. Real admin users should be
+ created through the /auth/setup endpoint.
+ """
+ cursor.execute("""
+ INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active)
+ VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE);
+ """)
+
+ def _update_client_state_table(self, cursor: sqlite3.Cursor) -> None:
+ """Restructure client_state table to support per-user storage."""
+ # Check if client_state table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='client_state';")
+ if cursor.fetchone() is None:
+ # Table doesn't exist, create it with the new schema
+ cursor.execute(
+ """
+ CREATE TABLE client_state (
+ user_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ PRIMARY KEY (user_id, key),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ );
+ """
+ )
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
+ cursor.execute(
+ """
+ CREATE TRIGGER tg_client_state_updated_at
+ AFTER UPDATE ON client_state
+ FOR EACH ROW
+ BEGIN
+ UPDATE client_state
+ SET updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = OLD.user_id AND key = OLD.key;
+ END;
+ """
+ )
+ return
+
+ # Table exists with old schema - migrate it
+ # Get existing data if the data column is present (it may be absent if an older
+ # version of migration 21 was deployed without the column)
+ cursor.execute("PRAGMA table_info(client_state);")
+ columns = [row[1] for row in cursor.fetchall()]
+ existing_data = {}
+ if "data" in columns:
+ cursor.execute("SELECT data FROM client_state WHERE id = 1;")
+ row = cursor.fetchone()
+ if row is not None:
+ try:
+ existing_data = json.loads(row[0])
+ except (json.JSONDecodeError, TypeError):
+ # If data is corrupt, just start fresh
+ pass
+
+ # Drop the old table
+ cursor.execute("DROP TABLE IF EXISTS client_state;")
+
+ # Create new table with per-user schema
+ cursor.execute(
+ """
+ CREATE TABLE client_state (
+ user_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ PRIMARY KEY (user_id, key),
+ FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
+ );
+ """
+ )
+
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);")
+
+ cursor.execute(
+ """
+ CREATE TRIGGER tg_client_state_updated_at
+ AFTER UPDATE ON client_state
+ FOR EACH ROW
+ BEGIN
+ UPDATE client_state
+ SET updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = OLD.user_id AND key = OLD.key;
+ END;
+ """
+ )
+
+ # Migrate existing data to 'system' user
+ for key, value in existing_data.items():
+ cursor.execute(
+ """
+ INSERT INTO client_state (user_id, key, value)
+ VALUES ('system', ?, ?);
+ """,
+ (key, value),
+ )
+
+ def _create_app_settings_table(self, cursor: sqlite3.Cursor) -> None:
+ """Create app_settings table for storing application-level configuration."""
+ cursor.execute(
+ """
+ CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT NOT NULL PRIMARY KEY,
+ value TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
+ );
+ """
+ )
+
+ cursor.execute(
+ """
+ CREATE TRIGGER IF NOT EXISTS tg_app_settings_updated_at
+ AFTER UPDATE ON app_settings
+ FOR EACH ROW
+ BEGIN
+ UPDATE app_settings SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
+ WHERE key = OLD.key;
+ END;
+ """
+ )
+
+ def _generate_jwt_secret(self, cursor: sqlite3.Cursor) -> None:
+ """Generate and store a cryptographically secure JWT secret key.
+
+ The secret is a 64-character hexadecimal string (256 bits of entropy),
+ which is suitable for HS256 JWT signing.
+ """
+ # Check if JWT secret already exists
+ cursor.execute("SELECT value FROM app_settings WHERE key = 'jwt_secret';")
+ existing_secret = cursor.fetchone()
+
+ if existing_secret is None:
+ # Generate a new cryptographically secure secret (256 bits)
+ jwt_secret = secrets.token_hex(32) # 32 bytes = 256 bits = 64 hex characters
+
+ # Store in database
+ cursor.execute(
+ "INSERT INTO app_settings (key, value) VALUES ('jwt_secret', ?);",
+ (jwt_secret,),
+ )
+
+
+def build_migration_27() -> Migration:
+ """Builds the migration object for migrating from version 26 to version 27.
+
+ This migration adds multi-user support, per-user client state, and app settings
+ (including a JWT secret) to the database schema.
+ """
+ return Migration(
+ from_version=26,
+ to_version=27,
+ callback=Migration27Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
new file mode 100644
index 00000000000..60e5d8f19bf
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
@@ -0,0 +1,48 @@
+"""Migration 28: Add per-user workflow isolation columns to workflow_library.
+
+This migration adds the database columns required for multiuser workflow isolation
+to the workflow_library table:
+- user_id: the owner of the workflow (defaults to 'system' for existing workflows)
+- is_public: whether the workflow is shared with all users
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration28Callback:
+ """Migration to add user_id and is_public to the workflow_library table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._update_workflow_library_table(cursor)
+
+ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to workflow_library table."""
+ cursor.execute("PRAGMA table_info(workflow_library);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
+ cursor.execute(
+ "UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';"
+ ) # one-time fix for legacy workflows
+
+
+def build_migration_28() -> Migration:
+ """Builds the migration object for migrating from version 27 to version 28.
+
+ This migration adds per-user workflow isolation to the workflow_library table:
+ - user_id column: identifies the owner of each workflow
+ - is_public column: controls whether a workflow is shared with all users
+ """
+ return Migration(
+ from_version=27,
+ to_version=28,
+ callback=Migration28Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
new file mode 100644
index 00000000000..c9eb7c901ba
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py
@@ -0,0 +1,53 @@
+"""Migration 29: Add board_visibility column to boards table.
+
+This migration adds a board_visibility column to the boards table to support
+three visibility levels:
+ - 'private': only the board owner (and admins) can view/modify
+ - 'shared': all users can view, but only the owner (and admins) can modify
+ - 'public': all users can view; only the owner (and admins) can modify the
+ board structure (rename/archive/delete)
+
+Existing boards with is_public = 1 are migrated to 'public'.
+All other existing boards default to 'private'.
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration29Callback:
+ """Migration to add board_visibility column to the boards table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._update_boards_table(cursor)
+
+ def _update_boards_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add board_visibility column to boards table."""
+ # Check if boards table exists
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(boards);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "board_visibility" not in columns:
+ cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);")
+ # Migrate existing is_public = 1 boards to 'public'
+ if "is_public" in columns:
+ cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;")
+
+
+def build_migration_29() -> Migration:
+ """Builds the migration object for migrating from version 28 to version 29.
+
+ This migration adds the board_visibility column to the boards table,
+ supporting 'private', 'shared', and 'public' visibility levels.
+ """
+ return Migration(
+ from_version=28,
+ to_version=29,
+ callback=Migration29Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py
new file mode 100644
index 00000000000..d60270bfa1c
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_30.py
@@ -0,0 +1,33 @@
+"""Migration 30: Add per-item queue status sequencing.
+
+This migration adds a `status_sequence` column to `session_queue` so queue item
+status updates can be ordered across asynchronous event and snapshot channels.
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration30Callback:
+ """Add a per-queue-item status sequence for cross-channel ordering."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(session_queue);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "status_sequence" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN status_sequence INTEGER DEFAULT 0;")
+ cursor.execute("UPDATE session_queue SET status_sequence = 0 WHERE status_sequence IS NULL;")
+
+
+def build_migration_30() -> Migration:
+ return Migration(
+ from_version=29,
+ to_version=30,
+ callback=Migration30Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_31.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_31.py
new file mode 100644
index 00000000000..9f5b36a5f2d
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_31.py
@@ -0,0 +1,41 @@
+"""Migration 31: Add image_subfolder column to images table.
+
+This migration adds an image_subfolder column to the images table to support
+configurable image subfolder strategies (flat, date, type, hash).
+Existing images get an empty string (flat/root directory).
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration31Callback:
+ """Migration to add image_subfolder column to images table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._add_image_subfolder_column(cursor)
+
+ def _add_image_subfolder_column(self, cursor: sqlite3.Cursor) -> None:
+ """Add image_subfolder column to images table."""
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(images);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "image_subfolder" not in columns:
+ cursor.execute("ALTER TABLE images ADD COLUMN image_subfolder TEXT NOT NULL DEFAULT '';")
+
+
+def build_migration_31() -> Migration:
+ """Builds the migration object for migrating from version 30 to version 31.
+
+ This migration adds an image_subfolder column to the images table.
+ """
+ return Migration(
+ from_version=30,
+ to_version=31,
+ callback=Migration31Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py
new file mode 100644
index 00000000000..5e06d634e76
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py
@@ -0,0 +1,91 @@
+"""Migration 32: Repair model_relationships foreign keys.
+
+Migration 22 rebuilt the `models` table by renaming it to `models_old`, creating a
+fresh `models` table, copying the data over, and dropping `models_old`. Because
+modern SQLite (with `legacy_alter_table` off) rewrites foreign-key references in
+*other* tables when a table is renamed, the foreign keys in `model_relationships`
+were silently repointed at `models_old` -- which was then dropped.
+
+This left the related-models links referencing a table that no longer exists,
+breaking `ON DELETE CASCADE` and foreign-key integrity for related models.
+
+This migration rebuilds `model_relationships` so its foreign keys reference
+`models(id)` again, preserving existing links and dropping any orphaned rows whose
+model keys no longer exist (those would violate the restored foreign keys).
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration32Callback:
+ """Migration to repair the broken foreign keys on the model_relationships table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._repair_model_relationships_fks(cursor)
+
+ def _repair_model_relationships_fks(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships';")
+ row = cursor.fetchone()
+ if row is None:
+ # Table does not exist (fresh db will create it correctly), nothing to repair.
+ return
+
+ existing_sql: str = row[0]
+ if "models_old" not in existing_sql:
+ # Foreign keys already point at the correct table, nothing to repair.
+ return
+
+ # Rebuild the table with the correct foreign keys referencing models(id).
+ cursor.execute("ALTER TABLE model_relationships RENAME TO model_relationships_old;")
+ cursor.execute(
+ """
+ -- many-to-many relationship table for models
+ CREATE TABLE model_relationships (
+ -- model_key_1 and model_key_2 are the same as the key(primary key) in the models table
+ model_key_1 TEXT NOT NULL,
+ model_key_2 TEXT NOT NULL,
+ created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
+ PRIMARY KEY (model_key_1, model_key_2),
+ -- model_key_1 < model_key_2, to ensure uniqueness and prevent duplicates
+ FOREIGN KEY (model_key_1) REFERENCES models(id) ON DELETE CASCADE,
+ FOREIGN KEY (model_key_2) REFERENCES models(id) ON DELETE CASCADE
+ );
+ """
+ )
+
+ # Copy over the existing links, dropping any orphaned rows whose model keys no
+ # longer exist -- these would violate the restored foreign keys.
+ cursor.execute(
+ """
+ INSERT INTO model_relationships (model_key_1, model_key_2, created_at)
+ SELECT model_key_1, model_key_2, created_at
+ FROM model_relationships_old
+ WHERE model_key_1 IN (SELECT id FROM models)
+ AND model_key_2 IN (SELECT id FROM models);
+ """
+ )
+
+ # Drop the old table first so its index name is freed before we recreate it.
+ cursor.execute("DROP TABLE model_relationships_old;")
+ cursor.execute(
+ """
+ -- Creates an index to keep performance equal when searching for model_key_1 or model_key_2
+ CREATE INDEX IF NOT EXISTS keyx_model_relationships_model_key_2
+ ON model_relationships(model_key_2);
+ """
+ )
+
+
+def build_migration_32() -> Migration:
+ """Builds the migration object for migrating from version 31 to version 32.
+
+ This migration repairs the foreign keys on the model_relationships table, which were
+ broken by migration 22 rebuilding the models table.
+ """
+ return Migration(
+ from_version=31,
+ to_version=32,
+ callback=Migration32Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py
new file mode 100644
index 00000000000..d99e1897137
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_33.py
@@ -0,0 +1,36 @@
+"""Migration 33: Add device column to session_queue table.
+
+This records which device (e.g. 'cuda:1') processed a queue item, so the UI can show a per-item
+GPU number in the Session Queue. Existing rows get NULL (unknown device).
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration33Callback:
+ """Migration to add a device column to the session_queue table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';")
+ if cursor.fetchone() is None:
+ return
+
+ cursor.execute("PRAGMA table_info(session_queue);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "device" not in columns:
+ cursor.execute("ALTER TABLE session_queue ADD COLUMN device TEXT;")
+
+
+def build_migration_33() -> Migration:
+ """Builds the migration object for migrating from version 32 to version 33.
+
+ This migration adds a device column to the session_queue table.
+ """
+ return Migration(
+ from_version=32,
+ to_version=33,
+ callback=Migration33Callback(),
+ )
diff --git a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py
index 5d78d55818c..310abf05200 100644
--- a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py
+++ b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py
@@ -32,7 +32,7 @@ class SqliteMigrator:
def __init__(self, db: SqliteDatabase) -> None:
self._db = db
- self._logger = db.logger
+ self._logger = db._logger
self._migration_set = MigrationSet()
self._backup_path: Optional[Path] = None
@@ -43,46 +43,45 @@ def register_migration(self, migration: Migration) -> None:
def run_migrations(self) -> bool:
"""Migrates the database to the latest version."""
- with self._db.lock:
- # This throws if there is a problem.
- self._migration_set.validate_migration_chain()
- cursor = self._db.conn.cursor()
- self._create_migrations_table(cursor=cursor)
-
- if self._migration_set.count == 0:
- self._logger.debug("No migrations registered")
- return False
-
- if self._get_current_version(cursor=cursor) == self._migration_set.latest_version:
- self._logger.debug("Database is up to date, no migrations to run")
- return False
-
- self._logger.info("Database update needed")
-
- # Make a backup of the db if it needs to be updated and is a file db
- if self._db.db_path is not None:
- timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
- self._backup_path = self._db.db_path.parent / f"{self._db.db_path.stem}_backup_{timestamp}.db"
- self._logger.info(f"Backing up database to {str(self._backup_path)}")
- # Use SQLite to do the backup
- with closing(sqlite3.connect(self._backup_path)) as backup_conn:
- self._db.conn.backup(backup_conn)
- else:
- self._logger.info("Using in-memory database, no backup needed")
-
- next_migration = self._migration_set.get(from_version=self._get_current_version(cursor))
- while next_migration is not None:
- self._run_migration(next_migration)
- next_migration = self._migration_set.get(self._get_current_version(cursor))
- self._logger.info("Database updated successfully")
- return True
+ # This throws if there is a problem.
+ self._migration_set.validate_migration_chain()
+ cursor = self._db._conn.cursor()
+ self._create_migrations_table(cursor=cursor)
+
+ if self._migration_set.count == 0:
+ self._logger.debug("No migrations registered")
+ return False
+
+ if self._get_current_version(cursor=cursor) == self._migration_set.latest_version:
+ self._logger.debug("Database is up to date, no migrations to run")
+ return False
+
+ self._logger.info("Database update needed")
+
+ # Make a backup of the db if it needs to be updated and is a file db
+ if self._db._db_path is not None:
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ self._backup_path = self._db._db_path.parent / f"{self._db._db_path.stem}_backup_{timestamp}.db"
+ self._logger.info(f"Backing up database to {str(self._backup_path)}")
+ # Use SQLite to do the backup
+ with closing(sqlite3.connect(self._backup_path)) as backup_conn:
+ self._db._conn.backup(backup_conn)
+ else:
+ self._logger.info("Using in-memory database, no backup needed")
+
+ next_migration = self._migration_set.get(from_version=self._get_current_version(cursor))
+ while next_migration is not None:
+ self._run_migration(next_migration)
+ next_migration = self._migration_set.get(self._get_current_version(cursor))
+ self._logger.info("Database updated successfully")
+ return True
def _run_migration(self, migration: Migration) -> None:
"""Runs a single migration."""
try:
# Using sqlite3.Connection as a context manager commits a the transaction on exit, or rolls it back if an
# exception is raised.
- with self._db.lock, self._db.conn as conn:
+ with self._db._conn as conn:
cursor = conn.cursor()
if self._get_current_version(cursor) != migration.from_version:
raise MigrationError(
@@ -108,27 +107,26 @@ def _run_migration(self, migration: Migration) -> None:
def _create_migrations_table(self, cursor: sqlite3.Cursor) -> None:
"""Creates the migrations table for the database, if one does not already exist."""
- with self._db.lock:
- try:
- cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
- if cursor.fetchone() is not None:
- return
- cursor.execute(
- """--sql
- CREATE TABLE migrations (
- version INTEGER PRIMARY KEY,
- migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
- );
- """
- )
- cursor.execute("INSERT INTO migrations (version) VALUES (0);")
- cursor.connection.commit()
- self._logger.debug("Created migrations table")
- except sqlite3.Error as e:
- msg = f"Problem creating migrations table: {e}"
- self._logger.error(msg)
- cursor.connection.rollback()
- raise MigrationError(msg) from e
+ try:
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';")
+ if cursor.fetchone() is not None:
+ return
+ cursor.execute(
+ """--sql
+ CREATE TABLE migrations (
+ version INTEGER PRIMARY KEY,
+ migrated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
+ );
+ """
+ )
+ cursor.execute("INSERT INTO migrations (version) VALUES (0);")
+ cursor.connection.commit()
+ self._logger.debug("Created migrations table")
+ except sqlite3.Error as e:
+ msg = f"Problem creating migrations table: {e}"
+ self._logger.error(msg)
+ cursor.connection.rollback()
+ raise MigrationError(msg) from e
@classmethod
def _get_current_version(cls, cursor: sqlite3.Cursor) -> int:
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png
new file mode 100644
index 00000000000..def6dce2592
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png
new file mode 100644
index 00000000000..97a2e74772f
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png
new file mode 100644
index 00000000000..5db78ce086f
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png
new file mode 100644
index 00000000000..93c3c5c301a
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png
new file mode 100644
index 00000000000..5d3d0c4af6e
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png
new file mode 100644
index 00000000000..3f287fc3359
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png
new file mode 100644
index 00000000000..a0e1cbfb423
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png
new file mode 100644
index 00000000000..5b5976c4f95
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png
new file mode 100644
index 00000000000..5c784103771
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png
new file mode 100644
index 00000000000..b8cdfea030f
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png
new file mode 100644
index 00000000000..b47da9fb941
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png
new file mode 100644
index 00000000000..a034cd197bc
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png
new file mode 100644
index 00000000000..5985fb6c4b2
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png
new file mode 100644
index 00000000000..7718735b23f
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png
new file mode 100644
index 00000000000..60bd40b1fa8
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png
new file mode 100644
index 00000000000..4a426f47692
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png
new file mode 100644
index 00000000000..08d240a29e6
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png differ
diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png
new file mode 100644
index 00000000000..73c4c8db087
Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png differ
diff --git a/tests/test_model_probe/sd-1/main/dreamshaper-8-inpainting/unet/diffusion_pytorch_model.fp16.safetensors b/invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py
similarity index 100%
rename from tests/test_model_probe/sd-1/main/dreamshaper-8-inpainting/unet/diffusion_pytorch_model.fp16.safetensors
rename to invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py
diff --git a/invokeai/app/services/style_preset_images/style_preset_images_base.py b/invokeai/app/services/style_preset_images/style_preset_images_base.py
new file mode 100644
index 00000000000..d8158ad2ae2
--- /dev/null
+++ b/invokeai/app/services/style_preset_images/style_preset_images_base.py
@@ -0,0 +1,33 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+from PIL.Image import Image as PILImageType
+
+
+class StylePresetImageFileStorageBase(ABC):
+ """Low-level service responsible for storing and retrieving image files."""
+
+ @abstractmethod
+ def get(self, style_preset_id: str) -> PILImageType:
+ """Retrieves a style preset image as PIL Image."""
+ pass
+
+ @abstractmethod
+ def get_path(self, style_preset_id: str) -> Path:
+ """Gets the internal path to a style preset image."""
+ pass
+
+ @abstractmethod
+ def get_url(self, style_preset_id: str) -> str | None:
+ """Gets the URL to fetch a style preset image."""
+ pass
+
+ @abstractmethod
+ def save(self, style_preset_id: str, image: PILImageType) -> None:
+ """Saves a style preset image."""
+ pass
+
+ @abstractmethod
+ def delete(self, style_preset_id: str) -> None:
+ """Deletes a style preset image."""
+ pass
diff --git a/invokeai/app/services/style_preset_images/style_preset_images_common.py b/invokeai/app/services/style_preset_images/style_preset_images_common.py
new file mode 100644
index 00000000000..054a12b82b7
--- /dev/null
+++ b/invokeai/app/services/style_preset_images/style_preset_images_common.py
@@ -0,0 +1,19 @@
+class StylePresetImageFileNotFoundException(Exception):
+ """Raised when an image file is not found in storage."""
+
+ def __init__(self, message: str = "Style preset image file not found"):
+ super().__init__(message)
+
+
+class StylePresetImageFileSaveException(Exception):
+ """Raised when an image cannot be saved."""
+
+ def __init__(self, message: str = "Style preset image file not saved"):
+ super().__init__(message)
+
+
+class StylePresetImageFileDeleteException(Exception):
+ """Raised when an image cannot be deleted."""
+
+ def __init__(self, message: str = "Style preset image file not deleted"):
+ super().__init__(message)
diff --git a/invokeai/app/services/style_preset_images/style_preset_images_disk.py b/invokeai/app/services/style_preset_images/style_preset_images_disk.py
new file mode 100644
index 00000000000..cd2b29efd2a
--- /dev/null
+++ b/invokeai/app/services/style_preset_images/style_preset_images_disk.py
@@ -0,0 +1,88 @@
+from pathlib import Path
+
+from PIL import Image
+from PIL.Image import Image as PILImageType
+
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase
+from invokeai.app.services.style_preset_images.style_preset_images_common import (
+ StylePresetImageFileDeleteException,
+ StylePresetImageFileNotFoundException,
+ StylePresetImageFileSaveException,
+)
+from invokeai.app.services.style_preset_records.style_preset_records_common import PresetType
+from invokeai.app.util.misc import uuid_string
+from invokeai.app.util.thumbnails import make_thumbnail
+
+
+class StylePresetImageFileStorageDisk(StylePresetImageFileStorageBase):
+ """Stores images on disk"""
+
+ def __init__(self, style_preset_images_folder: Path):
+ self._style_preset_images_folder = style_preset_images_folder
+ self._validate_storage_folders()
+
+ def start(self, invoker: Invoker) -> None:
+ self._invoker = invoker
+
+ def get(self, style_preset_id: str) -> PILImageType:
+ try:
+ path = self.get_path(style_preset_id)
+
+ return Image.open(path)
+ except FileNotFoundError as e:
+ raise StylePresetImageFileNotFoundException from e
+
+ def save(self, style_preset_id: str, image: PILImageType) -> None:
+ try:
+ self._validate_storage_folders()
+ image_path = self._style_preset_images_folder / (style_preset_id + ".webp")
+ thumbnail = make_thumbnail(image, 256)
+ thumbnail.save(image_path, format="webp")
+
+ except Exception as e:
+ raise StylePresetImageFileSaveException from e
+
+ def get_path(self, style_preset_id: str) -> Path:
+ style_preset = self._invoker.services.style_preset_records.get(style_preset_id)
+ if style_preset.type is PresetType.Default:
+ default_images_dir = Path(__file__).parent / Path("default_style_preset_images")
+ path = default_images_dir / (style_preset.name + ".png")
+ else:
+ path = self._style_preset_images_folder / (style_preset_id + ".webp")
+
+ return path
+
+ def get_url(self, style_preset_id: str) -> str | None:
+ path = self.get_path(style_preset_id)
+ if not self._validate_path(path):
+ return
+
+ url = self._invoker.services.urls.get_style_preset_image_url(style_preset_id)
+
+ # The image URL never changes, so we must add random query string to it to prevent caching
+ url += f"?{uuid_string()}"
+
+ return url
+
+ def delete(self, style_preset_id: str) -> None:
+ try:
+ path = self.get_path(style_preset_id)
+
+ if not self._validate_path(path):
+ raise StylePresetImageFileNotFoundException
+
+ path.unlink()
+
+ except StylePresetImageFileNotFoundException as e:
+ raise StylePresetImageFileNotFoundException from e
+ except Exception as e:
+ raise StylePresetImageFileDeleteException from e
+
+ def _validate_path(self, path: Path) -> bool:
+ """Validates the path given for an image."""
+ return path.exists()
+
+ def _validate_storage_folders(self) -> None:
+ """Checks if the required folders exist and create them if they don't"""
+ self._style_preset_images_folder.mkdir(parents=True, exist_ok=True)
diff --git a/tests/test_model_probe/vae/taesdxl-fp16/diffusion_pytorch_model.fp16.safetensors b/invokeai/app/services/style_preset_records/__init__.py
similarity index 100%
rename from tests/test_model_probe/vae/taesdxl-fp16/diffusion_pytorch_model.fp16.safetensors
rename to invokeai/app/services/style_preset_records/__init__.py
diff --git a/invokeai/app/services/style_preset_records/default_style_presets.json b/invokeai/app/services/style_preset_records/default_style_presets.json
new file mode 100644
index 00000000000..1daadfa8ff7
--- /dev/null
+++ b/invokeai/app/services/style_preset_records/default_style_presets.json
@@ -0,0 +1,146 @@
+[
+ {
+ "name": "Photography (General)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt}. photography. f/2.8 macro photo, bokeh, photorealism",
+ "negative_prompt": "painting, digital art. sketch, blurry"
+ }
+ },
+ {
+ "name": "Photography (Studio Lighting)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt}, photography. f/8 photo. centered subject, studio lighting.",
+ "negative_prompt": "painting, digital art. sketch, blurry"
+ }
+ },
+ {
+ "name": "Photography (Landscape)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt}, landscape photograph, f/12, lifelike, highly detailed.",
+ "negative_prompt": "painting, digital art. sketch, blurry"
+ }
+ },
+ {
+ "name": "Photography (Portrait)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt}. photography. portraiture. catch light in eyes. one flash. rembrandt lighting. Soft box. dark shadows. High contrast. 80mm lens. F2.8.",
+ "negative_prompt": "painting, digital art. sketch, blurry"
+ }
+ },
+ {
+ "name": "Photography (Black and White)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} photography. natural light. 80mm lens. F1.4. strong contrast, hard light. dark contrast. blurred background. black and white",
+ "negative_prompt": "painting, digital art. sketch, colour+"
+ }
+ },
+ {
+ "name": "Architectural Visualization",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt}. architectural photography, f/12, luxury, aesthetically pleasing form and function.",
+ "negative_prompt": "painting, digital art. sketch, blurry"
+ }
+ },
+ {
+ "name": "Concept Art (Fantasy)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "concept artwork of a {prompt}. (digital painterly art style)++, mythological, (textured 2d dry media brushpack)++, glazed brushstrokes, otherworldly. painting+, illustration+",
+ "negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
+ }
+ },
+ {
+ "name": "Concept Art (Sci-Fi)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "(concept art)++, {prompt}, (sleek futurism)++, (textured 2d dry media)++, metallic highlights, digital painting style",
+ "negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
+ }
+ },
+ {
+ "name": "Concept Art (Character)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "(character concept art)++, stylized painterly digital painting of {prompt}, (painterly, impasto. Dry brush.)++",
+ "negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
+ }
+ },
+ {
+ "name": "Concept Art (Painterly)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} oil painting. high contrast. impasto. sfumato. chiaroscuro. Palette knife.",
+ "negative_prompt": "photo. smooth. border. frame"
+ }
+ },
+ {
+ "name": "Environment Art",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} environment artwork, hyper-realistic digital painting style with cinematic composition, atmospheric, depth and detail, voluminous. textured dry brush 2d media",
+ "negative_prompt": "photo, distorted, blurry, out of focus. sketch."
+ }
+ },
+ {
+ "name": "Interior Design (Visualization)",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} interior design photo, gentle shadows, light mid-tones, dimension, mix of smooth and textured surfaces, focus on negative space and clean lines, focus",
+ "negative_prompt": "photo, distorted. sketch."
+ }
+ },
+ {
+ "name": "Product Rendering",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} high quality product photography, 3d rendering with key lighting, shallow depth of field, simple plain background, studio lighting.",
+ "negative_prompt": "blurry, sketch, messy, dirty. unfinished."
+ }
+ },
+ {
+ "name": "Sketch",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} black and white pencil drawing, off-center composition, cross-hatching for shadows, bold strokes, textured paper. sketch+++",
+ "negative_prompt": "blurry, photo, painting, color. messy, dirty. unfinished. frame, borders."
+ }
+ },
+ {
+ "name": "Line Art",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} Line art. bold outline. simplistic. white background. 2d",
+ "negative_prompt": "photo. digital art. greyscale. solid black. painting"
+ }
+ },
+ {
+ "name": "Anime",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} anime++, bold outline, cel-shaded coloring, shounen, seinen",
+ "negative_prompt": "(photo)+++. greyscale. solid black. painting"
+ }
+ },
+ {
+ "name": "Illustration",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "{prompt} illustration, bold linework, illustrative details, vector art style, flat coloring",
+ "negative_prompt": "(photo)+++. greyscale. painting, black and white."
+ }
+ },
+ {
+ "name": "Vehicles",
+ "type": "default",
+ "preset_data": {
+ "positive_prompt": "A weird futuristic normal auto, {prompt} elegant design, nice color, nice wheels",
+ "negative_prompt": "sketch. digital art. greyscale. painting"
+ }
+ }
+]
diff --git a/invokeai/app/services/style_preset_records/style_preset_records_base.py b/invokeai/app/services/style_preset_records/style_preset_records_base.py
new file mode 100644
index 00000000000..87437a8dc0d
--- /dev/null
+++ b/invokeai/app/services/style_preset_records/style_preset_records_base.py
@@ -0,0 +1,53 @@
+from abc import ABC, abstractmethod
+
+from invokeai.app.services.style_preset_records.style_preset_records_common import (
+ PresetType,
+ StylePresetChanges,
+ StylePresetRecordDTO,
+ StylePresetWithoutId,
+)
+
+
+class StylePresetRecordsStorageBase(ABC):
+ """Base class for style preset storage services."""
+
+ @abstractmethod
+ def get(self, style_preset_id: str) -> StylePresetRecordDTO:
+ """Get style preset by id. Authorization is the caller's responsibility."""
+ pass
+
+ @abstractmethod
+ def create(self, style_preset: StylePresetWithoutId, user_id: str) -> StylePresetRecordDTO:
+ """Creates a style preset owned by user_id."""
+ pass
+
+ @abstractmethod
+ def create_many(self, style_presets: list[StylePresetWithoutId], user_id: str) -> None:
+ """Creates many style presets owned by user_id."""
+ pass
+
+ @abstractmethod
+ def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO:
+ """Updates a style preset. Authorization is the caller's responsibility."""
+ pass
+
+ @abstractmethod
+ def delete(self, style_preset_id: str) -> None:
+ """Deletes a style preset. Authorization is the caller's responsibility."""
+ pass
+
+ @abstractmethod
+ def get_many(
+ self,
+ type: PresetType | None = None,
+ user_id: str | None = None,
+ is_admin: bool = False,
+ ) -> list[StylePresetRecordDTO]:
+ """Gets style presets visible to user_id.
+
+ Visibility rules:
+ - is_admin=True: all presets.
+ - Else: presets owned by user_id, plus all `default` presets, plus any public preset.
+ - If user_id is None and is_admin is False: only `default` and public presets.
+ """
+ pass
diff --git a/invokeai/app/services/style_preset_records/style_preset_records_common.py b/invokeai/app/services/style_preset_records/style_preset_records_common.py
new file mode 100644
index 00000000000..9e6df88c989
--- /dev/null
+++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py
@@ -0,0 +1,141 @@
+import codecs
+import csv
+import json
+from enum import Enum
+from typing import Any, Optional
+
+import pydantic
+from fastapi import UploadFile
+from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter
+
+from invokeai.app.util.metaenum import MetaEnum
+
+
+class StylePresetNotFoundError(Exception):
+ """Raised when a style preset is not found"""
+
+
+class PresetData(BaseModel, extra="forbid"):
+ positive_prompt: str = Field(description="Positive prompt")
+ negative_prompt: str = Field(description="Negative prompt")
+
+
+PresetDataValidator = TypeAdapter(PresetData)
+
+
+class PresetType(str, Enum, metaclass=MetaEnum):
+ User = "user"
+ Default = "default"
+
+
+class StylePresetChanges(BaseModel, extra="forbid"):
+ name: Optional[str] = Field(default=None, description="The style preset's new name.")
+ preset_data: Optional[PresetData] = Field(default=None, description="The updated data for style preset.")
+ type: Optional[PresetType] = Field(description="The updated type of the style preset")
+ is_public: Optional[bool] = Field(default=None, description="Whether the preset is visible to other users.")
+
+
+class StylePresetWithoutId(BaseModel):
+ name: str = Field(description="The name of the style preset.")
+ preset_data: PresetData = Field(description="The preset data")
+ type: PresetType = Field(description="The type of style preset")
+ is_public: bool = Field(default=False, description="Whether the preset is visible to other users.")
+
+
+class StylePresetRecordDTO(StylePresetWithoutId):
+ id: str = Field(description="The style preset ID.")
+ user_id: str = Field(description="The user who owns this style preset.")
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> "StylePresetRecordDTO":
+ data["preset_data"] = PresetDataValidator.validate_json(data.get("preset_data", ""))
+ return StylePresetRecordDTOValidator.validate_python(data)
+
+
+StylePresetRecordDTOValidator = TypeAdapter(StylePresetRecordDTO)
+
+
+class StylePresetRecordWithImage(StylePresetRecordDTO):
+ image: Optional[str] = Field(description="The path for image")
+
+
+class StylePresetImportRow(BaseModel):
+ name: str = Field(min_length=1, description="The name of the preset.")
+ positive_prompt: str = Field(
+ default="",
+ description="The positive prompt for the preset.",
+ validation_alias=AliasChoices("positive_prompt", "prompt"),
+ )
+ negative_prompt: str = Field(default="", description="The negative prompt for the preset.")
+
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
+
+
+StylePresetImportList = list[StylePresetImportRow]
+StylePresetImportListTypeAdapter = TypeAdapter(StylePresetImportList)
+
+
+class UnsupportedFileTypeError(ValueError):
+ """Raised when an unsupported file type is encountered"""
+
+ pass
+
+
+class InvalidPresetImportDataError(ValueError):
+ """Raised when invalid preset import data is encountered"""
+
+ pass
+
+
+async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId]:
+ """Parses style presets from a file. The file must be a CSV or JSON file.
+
+ If CSV, the file must have the following columns:
+ - name
+ - prompt (or positive_prompt)
+ - negative_prompt
+
+ If JSON, the file must be a list of objects with the following keys:
+ - name
+ - prompt (or positive_prompt)
+ - negative_prompt
+
+ Args:
+ file (UploadFile): The file to parse.
+
+ Returns:
+ list[StylePresetWithoutId]: The parsed style presets.
+
+ Raises:
+ UnsupportedFileTypeError: If the file type is not supported.
+ InvalidPresetImportDataError: If the data in the file is invalid.
+ """
+ if file.content_type not in ["text/csv", "application/json"]:
+ raise UnsupportedFileTypeError()
+
+ if file.content_type == "text/csv":
+ csv_reader = csv.DictReader(codecs.iterdecode(file.file, "utf-8"))
+ data = list(csv_reader)
+ else: # file.content_type == "application/json":
+ json_data = await file.read()
+ data = json.loads(json_data)
+
+ try:
+ imported_presets = StylePresetImportListTypeAdapter.validate_python(data)
+
+ style_presets: list[StylePresetWithoutId] = []
+
+ for imported in imported_presets:
+ preset_data = PresetData(positive_prompt=imported.positive_prompt, negative_prompt=imported.negative_prompt)
+ style_preset = StylePresetWithoutId(name=imported.name, preset_data=preset_data, type=PresetType.User)
+ style_presets.append(style_preset)
+ except pydantic.ValidationError as e:
+ if file.content_type == "text/csv":
+ msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
+ else: # file.content_type == "application/json":
+ msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
+ raise InvalidPresetImportDataError(msg) from e
+ finally:
+ file.file.close()
+
+ return style_presets
diff --git a/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py
new file mode 100644
index 00000000000..03397133ae9
--- /dev/null
+++ b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py
@@ -0,0 +1,188 @@
+import json
+from pathlib import Path
+
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase
+from invokeai.app.services.style_preset_records.style_preset_records_common import (
+ PresetType,
+ StylePresetChanges,
+ StylePresetNotFoundError,
+ StylePresetRecordDTO,
+ StylePresetWithoutId,
+)
+from invokeai.app.util.misc import uuid_string
+
+# System user id used for default / shipped presets and for legacy rows pre-dating
+# the per-user ownership columns added in migration 27.
+SYSTEM_USER_ID = "system"
+
+
+class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
+ def __init__(self, db: SqliteDatabase) -> None:
+ super().__init__()
+ self._db = db
+
+ def start(self, invoker: Invoker) -> None:
+ self._invoker = invoker
+ self._sync_default_style_presets()
+
+ def get(self, style_preset_id: str) -> StylePresetRecordDTO:
+ """Gets a style preset by ID."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ SELECT *
+ FROM style_presets
+ WHERE id = ?;
+ """,
+ (style_preset_id,),
+ )
+ row = cursor.fetchone()
+ if row is None:
+ raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found")
+ return StylePresetRecordDTO.from_dict(dict(row))
+
+ def create(self, style_preset: StylePresetWithoutId, user_id: str) -> StylePresetRecordDTO:
+ style_preset_id = uuid_string()
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO style_presets (
+ id,
+ name,
+ preset_data,
+ type,
+ user_id,
+ is_public
+ )
+ VALUES (?, ?, ?, ?, ?, ?);
+ """,
+ (
+ style_preset_id,
+ style_preset.name,
+ style_preset.preset_data.model_dump_json(),
+ style_preset.type,
+ user_id,
+ 1 if style_preset.is_public else 0,
+ ),
+ )
+ return self.get(style_preset_id)
+
+ def create_many(self, style_presets: list[StylePresetWithoutId], user_id: str) -> None:
+ with self._db.transaction() as cursor:
+ for style_preset in style_presets:
+ style_preset_id = uuid_string()
+ cursor.execute(
+ """--sql
+ INSERT OR IGNORE INTO style_presets (
+ id,
+ name,
+ preset_data,
+ type,
+ user_id,
+ is_public
+ )
+ VALUES (?, ?, ?, ?, ?, ?);
+ """,
+ (
+ style_preset_id,
+ style_preset.name,
+ style_preset.preset_data.model_dump_json(),
+ style_preset.type,
+ user_id,
+ 1 if style_preset.is_public else 0,
+ ),
+ )
+
+ return None
+
+ def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO:
+ with self._db.transaction() as cursor:
+ if changes.name is not None:
+ cursor.execute(
+ """--sql
+ UPDATE style_presets
+ SET name = ?
+ WHERE id = ?;
+ """,
+ (changes.name, style_preset_id),
+ )
+
+ if changes.preset_data is not None:
+ cursor.execute(
+ """--sql
+ UPDATE style_presets
+ SET preset_data = ?
+ WHERE id = ?;
+ """,
+ (changes.preset_data.model_dump_json(), style_preset_id),
+ )
+
+ if changes.is_public is not None:
+ cursor.execute(
+ """--sql
+ UPDATE style_presets
+ SET is_public = ?
+ WHERE id = ?;
+ """,
+ (1 if changes.is_public else 0, style_preset_id),
+ )
+
+ return self.get(style_preset_id)
+
+ def delete(self, style_preset_id: str) -> None:
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ DELETE from style_presets
+ WHERE id = ?;
+ """,
+ (style_preset_id,),
+ )
+ return None
+
+ def get_many(
+ self,
+ type: PresetType | None = None,
+ user_id: str | None = None,
+ is_admin: bool = False,
+ ) -> list[StylePresetRecordDTO]:
+ clauses: list[str] = []
+ params: list[object] = []
+
+ if not is_admin:
+ # Visible to non-admin: own + default + public.
+ visibility = "(type = 'default' OR is_public = 1"
+ if user_id is not None:
+ visibility += " OR user_id = ?"
+ params.append(user_id)
+ visibility += ")"
+ clauses.append(visibility)
+
+ if type is not None:
+ clauses.append("type = ?")
+ params.append(type)
+
+ where = f"WHERE {' AND '.join(clauses)} " if clauses else ""
+ query = f"SELECT * FROM style_presets {where}ORDER BY LOWER(name) ASC"
+
+ with self._db.transaction() as cursor:
+ cursor.execute(query, params)
+ rows = cursor.fetchall()
+ return [StylePresetRecordDTO.from_dict(dict(row)) for row in rows]
+
+ def _sync_default_style_presets(self) -> None:
+ """Syncs default style presets to the database. Internal use only."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ DELETE FROM style_presets
+ WHERE type = "default";
+ """
+ )
+ with open(Path(__file__).parent / Path("default_style_presets.json"), "r") as file:
+ presets = json.load(file)
+ for preset in presets:
+ style_preset = StylePresetWithoutId.model_validate(preset)
+ self.create(style_preset, user_id=SYSTEM_USER_ID)
diff --git a/invokeai/app/services/urls/urls_base.py b/invokeai/app/services/urls/urls_base.py
index 477ef046240..a5602abb3b4 100644
--- a/invokeai/app/services/urls/urls_base.py
+++ b/invokeai/app/services/urls/urls_base.py
@@ -13,3 +13,13 @@ def get_image_url(self, image_name: str, thumbnail: bool = False) -> str:
def get_model_image_url(self, model_key: str) -> str:
"""Gets the URL for a model image"""
pass
+
+ @abstractmethod
+ def get_style_preset_image_url(self, style_preset_id: str) -> str:
+ """Gets the URL for a style preset image"""
+ pass
+
+ @abstractmethod
+ def get_workflow_thumbnail_url(self, workflow_id: str) -> str:
+ """Gets the URL for a workflow thumbnail"""
+ pass
diff --git a/invokeai/app/services/urls/urls_default.py b/invokeai/app/services/urls/urls_default.py
index ff5071333f6..2e4f36d9d51 100644
--- a/invokeai/app/services/urls/urls_default.py
+++ b/invokeai/app/services/urls/urls_default.py
@@ -1,6 +1,6 @@
import os
-from .urls_base import UrlServiceBase
+from invokeai.app.services.urls.urls_base import UrlServiceBase
class LocalUrlService(UrlServiceBase):
@@ -19,3 +19,9 @@ def get_image_url(self, image_name: str, thumbnail: bool = False) -> str:
def get_model_image_url(self, model_key: str) -> str:
return f"{self._base_url_v2}/models/i/{model_key}/image"
+
+ def get_style_preset_image_url(self, style_preset_id: str) -> str:
+ return f"{self._base_url}/style_presets/i/{style_preset_id}/image"
+
+ def get_workflow_thumbnail_url(self, workflow_id: str) -> str:
+ return f"{self._base_url}/workflows/i/{workflow_id}/thumbnail"
diff --git a/invokeai/app/services/users/__init__.py b/invokeai/app/services/users/__init__.py
new file mode 100644
index 00000000000..f4976759504
--- /dev/null
+++ b/invokeai/app/services/users/__init__.py
@@ -0,0 +1 @@
+"""User service module."""
diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py
new file mode 100644
index 00000000000..dd789b561ee
--- /dev/null
+++ b/invokeai/app/services/users/users_base.py
@@ -0,0 +1,150 @@
+"""Abstract base class for user service."""
+
+from abc import ABC, abstractmethod
+
+from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest
+
+
+class UserServiceBase(ABC):
+ """High-level service for user management."""
+
+ @abstractmethod
+ def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Create a new user.
+
+ Args:
+ user_data: User creation data
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
+
+ Returns:
+ The created user
+
+ Raises:
+ ValueError: If email already exists or (when strict) password is weak
+ """
+ pass
+
+ @abstractmethod
+ def get(self, user_id: str) -> UserDTO | None:
+ """Get user by ID.
+
+ Args:
+ user_id: The user ID
+
+ Returns:
+ UserDTO if found, None otherwise
+ """
+ pass
+
+ @abstractmethod
+ def get_by_email(self, email: str) -> UserDTO | None:
+ """Get user by email.
+
+ Args:
+ email: The email address
+
+ Returns:
+ UserDTO if found, None otherwise
+ """
+ pass
+
+ @abstractmethod
+ def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Update user.
+
+ Args:
+ user_id: The user ID
+ changes: Fields to update
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
+
+ Returns:
+ The updated user
+
+ Raises:
+ ValueError: If user not found or (when strict) password is weak
+ """
+ pass
+
+ @abstractmethod
+ def delete(self, user_id: str) -> None:
+ """Delete user.
+
+ Args:
+ user_id: The user ID
+
+ Raises:
+ ValueError: If user not found
+ """
+ pass
+
+ @abstractmethod
+ def authenticate(self, email: str, password: str) -> UserDTO | None:
+ """Authenticate user credentials.
+
+ Args:
+ email: User email
+ password: User password
+
+ Returns:
+ UserDTO if authentication successful, None otherwise
+ """
+ pass
+
+ @abstractmethod
+ def has_admin(self) -> bool:
+ """Check if any admin user exists.
+
+ Returns:
+ True if at least one admin user exists, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Create an admin user (for initial setup).
+
+ Args:
+ user_data: User creation data
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
+
+ Returns:
+ The created admin user
+
+ Raises:
+ ValueError: If admin already exists or (when strict) password is weak
+ """
+ pass
+
+ @abstractmethod
+ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
+ """List all users.
+
+ Args:
+ limit: Maximum number of users to return
+ offset: Number of users to skip
+
+ Returns:
+ List of users
+ """
+ pass
+
+ @abstractmethod
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user.
+
+ Returns:
+ Email address of the first active admin, or None if no admin exists
+ """
+ pass
+
+ @abstractmethod
+ def count_admins(self) -> int:
+ """Count active admin users.
+
+ Returns:
+ The number of active admin users
+ """
+ pass
diff --git a/invokeai/app/services/users/users_common.py b/invokeai/app/services/users/users_common.py
new file mode 100644
index 00000000000..c13150a3369
--- /dev/null
+++ b/invokeai/app/services/users/users_common.py
@@ -0,0 +1,114 @@
+"""Common types and data models for user service."""
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator
+from pydantic_core import PydanticCustomError
+
+
+def validate_email_with_special_domains(email: str) -> str:
+ """Validate email address, allowing special-use domains like .local for testing.
+
+ This validator first tries standard email validation using email-validator library.
+ If it fails due to special-use domains (like .local, .test, .localhost), it performs
+ a basic syntax check instead. This allows development/testing with non-routable domains
+ while still catching actual typos and malformed emails.
+
+ Args:
+ email: The email address to validate
+
+ Returns:
+ The validated email address (lowercased)
+
+ Raises:
+ PydanticCustomError: If the email format is invalid
+ """
+ try:
+ # Try standard email validation using email-validator
+ from email_validator import EmailNotValidError, validate_email
+
+ result = validate_email(email, check_deliverability=False)
+ return result.normalized
+ except EmailNotValidError as e:
+ error_msg = str(e)
+
+ # Check if the error is specifically about special-use/reserved domains or localhost
+ if (
+ "special-use" in error_msg.lower()
+ or "reserved" in error_msg.lower()
+ or "should have a period" in error_msg.lower()
+ ):
+ # Perform basic email syntax validation
+ email = email.strip().lower()
+
+ if "@" not in email:
+ raise PydanticCustomError(
+ "value_error",
+ "Email address must contain an @ symbol",
+ )
+
+ local_part, domain = email.rsplit("@", 1)
+
+ if not local_part or not domain:
+ raise PydanticCustomError(
+ "value_error",
+ "Email address must have both local and domain parts",
+ )
+
+ # Allow localhost and domains with dots
+ if domain == "localhost" or "." in domain:
+ return email
+
+ raise PydanticCustomError(
+ "value_error",
+ "Email domain must contain a dot or be 'localhost'",
+ )
+ else:
+ # Re-raise other validation errors
+ raise PydanticCustomError(
+ "value_error",
+ f"Invalid email address: {error_msg}",
+ )
+
+
+class UserDTO(BaseModel):
+ """User data transfer object."""
+
+ user_id: str = Field(description="Unique user identifier")
+ email: str = Field(description="User email address")
+ display_name: str | None = Field(default=None, description="Display name")
+ is_admin: bool = Field(default=False, description="Whether user has admin privileges")
+ is_active: bool = Field(default=True, description="Whether user account is active")
+ created_at: datetime = Field(description="When the user was created")
+ updated_at: datetime = Field(description="When the user was last updated")
+ last_login_at: datetime | None = Field(default=None, description="When user last logged in")
+
+ @field_validator("email")
+ @classmethod
+ def validate_email(cls, v: str) -> str:
+ """Validate email address, allowing special-use domains."""
+ return validate_email_with_special_domains(v)
+
+
+class UserCreateRequest(BaseModel):
+ """Request to create a new user."""
+
+ email: str = Field(description="User email address")
+ display_name: str | None = Field(default=None, description="Display name")
+ password: str = Field(description="User password")
+ is_admin: bool = Field(default=False, description="Whether user should have admin privileges")
+
+ @field_validator("email")
+ @classmethod
+ def validate_email(cls, v: str) -> str:
+ """Validate email address, allowing special-use domains."""
+ return validate_email_with_special_domains(v)
+
+
+class UserUpdateRequest(BaseModel):
+ """Request to update a user."""
+
+ display_name: str | None = Field(default=None, description="Display name")
+ password: str | None = Field(default=None, description="New password")
+ is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges")
+ is_active: bool | None = Field(default=None, description="Whether user account should be active")
diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py
new file mode 100644
index 00000000000..6e472882124
--- /dev/null
+++ b/invokeai/app/services/users/users_default.py
@@ -0,0 +1,278 @@
+"""Default SQLite implementation of user service."""
+
+import sqlite3
+from datetime import datetime, timezone
+from uuid import uuid4
+
+from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password
+from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+from invokeai.app.services.users.users_base import UserServiceBase
+from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest
+
+
+class UserService(UserServiceBase):
+ """SQLite-based user service."""
+
+ def __init__(self, db: SqliteDatabase):
+ """Initialize user service.
+
+ Args:
+ db: SQLite database instance
+ """
+ self._db = db
+
+ def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Create a new user."""
+ # Validate password strength
+ if strict_password_checking:
+ is_valid, error_msg = validate_password_strength(user_data.password)
+ if not is_valid:
+ raise ValueError(error_msg)
+ elif not user_data.password:
+ raise ValueError("Password cannot be empty")
+
+ # Check if email already exists
+ if self.get_by_email(user_data.email) is not None:
+ raise ValueError(f"User with email {user_data.email} already exists")
+
+ user_id = str(uuid4())
+ password_hash = hash_password(user_data.password)
+
+ with self._db.transaction() as cursor:
+ try:
+ cursor.execute(
+ """
+ INSERT INTO users (user_id, email, display_name, password_hash, is_admin)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (user_id, user_data.email, user_data.display_name, password_hash, user_data.is_admin),
+ )
+ except sqlite3.IntegrityError as e:
+ raise ValueError(f"Failed to create user: {e}") from e
+
+ user = self.get(user_id)
+ if user is None:
+ raise RuntimeError("Failed to retrieve created user")
+ return user
+
+ def get(self, user_id: str) -> UserDTO | None:
+ """Get user by ID."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
+ FROM users
+ WHERE user_id = ?
+ """,
+ (user_id,),
+ )
+ row = cursor.fetchone()
+
+ if row is None:
+ return None
+
+ return UserDTO(
+ user_id=row[0],
+ email=row[1],
+ display_name=row[2],
+ is_admin=bool(row[3]),
+ is_active=bool(row[4]),
+ created_at=datetime.fromisoformat(row[5]),
+ updated_at=datetime.fromisoformat(row[6]),
+ last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
+ )
+
+ def get_by_email(self, email: str) -> UserDTO | None:
+ """Get user by email."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
+ FROM users
+ WHERE email = ?
+ """,
+ (email,),
+ )
+ row = cursor.fetchone()
+
+ if row is None:
+ return None
+
+ return UserDTO(
+ user_id=row[0],
+ email=row[1],
+ display_name=row[2],
+ is_admin=bool(row[3]),
+ is_active=bool(row[4]),
+ created_at=datetime.fromisoformat(row[5]),
+ updated_at=datetime.fromisoformat(row[6]),
+ last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
+ )
+
+ def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Update user."""
+ # Check if user exists
+ user = self.get(user_id)
+ if user is None:
+ raise ValueError(f"User {user_id} not found")
+
+ # Validate password if provided
+ if changes.password is not None:
+ if strict_password_checking:
+ is_valid, error_msg = validate_password_strength(changes.password)
+ if not is_valid:
+ raise ValueError(error_msg)
+ elif not changes.password:
+ raise ValueError("Password cannot be empty")
+
+ # Build update query dynamically based on provided fields
+ updates: list[str] = []
+ params: list[str | bool | int] = []
+
+ if changes.display_name is not None:
+ updates.append("display_name = ?")
+ params.append(changes.display_name)
+
+ if changes.password is not None:
+ updates.append("password_hash = ?")
+ params.append(hash_password(changes.password))
+
+ if changes.is_admin is not None:
+ updates.append("is_admin = ?")
+ params.append(changes.is_admin)
+
+ if changes.is_active is not None:
+ updates.append("is_active = ?")
+ params.append(changes.is_active)
+
+ if not updates:
+ return user
+
+ params.append(user_id)
+ query = f"UPDATE users SET {', '.join(updates)} WHERE user_id = ?"
+
+ with self._db.transaction() as cursor:
+ cursor.execute(query, params)
+
+ updated_user = self.get(user_id)
+ if updated_user is None:
+ raise RuntimeError("Failed to retrieve updated user")
+ return updated_user
+
+ def delete(self, user_id: str) -> None:
+ """Delete user."""
+ user = self.get(user_id)
+ if user is None:
+ raise ValueError(f"User {user_id} not found")
+
+ with self._db.transaction() as cursor:
+ cursor.execute("DELETE FROM users WHERE user_id = ?", (user_id,))
+
+ def authenticate(self, email: str, password: str) -> UserDTO | None:
+ """Authenticate user credentials."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT user_id, email, display_name, password_hash, is_admin, is_active, created_at, updated_at, last_login_at
+ FROM users
+ WHERE email = ?
+ """,
+ (email,),
+ )
+ row = cursor.fetchone()
+
+ if row is None:
+ return None
+
+ password_hash = row[3]
+ if not verify_password(password, password_hash):
+ return None
+
+ # Update last login time
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ "UPDATE users SET last_login_at = ? WHERE user_id = ?",
+ (datetime.now(timezone.utc).isoformat(), row[0]),
+ )
+
+ return UserDTO(
+ user_id=row[0],
+ email=row[1],
+ display_name=row[2],
+ is_admin=bool(row[4]),
+ is_active=bool(row[5]),
+ created_at=datetime.fromisoformat(row[6]),
+ updated_at=datetime.fromisoformat(row[7]),
+ last_login_at=datetime.now(timezone.utc),
+ )
+
+ def has_admin(self) -> bool:
+ """Check if any admin user exists."""
+ with self._db.transaction() as cursor:
+ cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE")
+ row = cursor.fetchone()
+ count = row[0] if row else 0
+ return bool(count > 0)
+
+ def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
+ """Create an admin user (for initial setup)."""
+ if self.has_admin():
+ raise ValueError("Admin user already exists")
+
+ # Force is_admin to True
+ admin_data = UserCreateRequest(
+ email=user_data.email,
+ display_name=user_data.display_name,
+ password=user_data.password,
+ is_admin=True,
+ )
+ return self.create(admin_data, strict_password_checking=strict_password_checking)
+
+ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
+ """List all users."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at
+ FROM users
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ """,
+ (limit, offset),
+ )
+ rows = cursor.fetchall()
+
+ return [
+ UserDTO(
+ user_id=row[0],
+ email=row[1],
+ display_name=row[2],
+ is_admin=bool(row[3]),
+ is_active=bool(row[4]),
+ created_at=datetime.fromisoformat(row[5]),
+ updated_at=datetime.fromisoformat(row[6]),
+ last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
+ )
+ for row in rows
+ ]
+
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT email FROM users
+ WHERE is_admin = TRUE AND is_active = TRUE
+ ORDER BY created_at ASC
+ LIMIT 1
+ """,
+ )
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+ def count_admins(self) -> int:
+ """Count active admin users."""
+ with self._db.transaction() as cursor:
+ cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE")
+ row = cursor.fetchone()
+ return int(row[0]) if row else 0
diff --git a/invokeai/app/services/virtual_boards/__init__.py b/invokeai/app/services/virtual_boards/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/invokeai/app/services/virtual_boards/virtual_boards_common.py b/invokeai/app/services/virtual_boards/virtual_boards_common.py
new file mode 100644
index 00000000000..e1df5a81ca5
--- /dev/null
+++ b/invokeai/app/services/virtual_boards/virtual_boards_common.py
@@ -0,0 +1,14 @@
+from typing import Optional
+
+from pydantic import BaseModel, Field
+
+
+class VirtualSubBoardDTO(BaseModel):
+ """A virtual sub-board computed from image metadata, not stored in the database."""
+
+ virtual_board_id: str = Field(description="The virtual board ID, e.g. 'by_date:2026-03-18'.")
+ board_name: str = Field(description="The display name of the virtual sub-board, e.g. '2026-03-18'.")
+ date: str = Field(description="The ISO date string, e.g. '2026-03-18'.")
+ image_count: int = Field(description="The number of general images for this date.")
+ asset_count: int = Field(description="The number of asset images for this date.")
+ cover_image_name: Optional[str] = Field(default=None, description="The most recent image name for this date.")
diff --git a/invokeai/app/services/workflow_records/default_workflows/CogView4_TextToImage.json b/invokeai/app/services/workflow_records/default_workflows/CogView4_TextToImage.json
new file mode 100644
index 00000000000..5318ba3e615
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/CogView4_TextToImage.json
@@ -0,0 +1,343 @@
+{
+ "name": "Text to Image - CogView4",
+ "author": "",
+ "description": "Generate an image from a prompt with CogView4.",
+ "version": "",
+ "contact": "",
+ "tags": "CogView4, Text to Image",
+ "notes": "",
+ "exposedFields": [],
+ "meta": { "category": "default", "version": "3.0.0" },
+ "id": "default_0e405a8e-ab5e-4e6c-bd99-b59deabd5591",
+ "form": {
+ "elements": {
+ "container-XSINSu999B": {
+ "id": "container-XSINSu999B",
+ "data": {
+ "layout": "column",
+ "children": [
+ "heading-N0TXlsboP5",
+ "text-PVw8AvXCTz",
+ "divider-5wmCOm9mqG",
+ "node-field-gPil4XSw8L",
+ "node-field-T2oYYNrAzH",
+ "node-field-SRj6Dn28lm"
+ ]
+ },
+ "type": "container"
+ },
+ "node-field-gPil4XSw8L": {
+ "id": "node-field-gPil4XSw8L",
+ "type": "node-field",
+ "parentId": "container-XSINSu999B",
+ "data": {
+ "fieldIdentifier": {
+ "nodeId": "a4569d8b-6a43-44b9-8919-4ceec6682904",
+ "fieldName": "prompt"
+ },
+ "settings": {
+ "type": "string-field-config",
+ "component": "textarea"
+ },
+ "showDescription": false
+ }
+ },
+ "node-field-T2oYYNrAzH": {
+ "id": "node-field-T2oYYNrAzH",
+ "type": "node-field",
+ "parentId": "container-XSINSu999B",
+ "data": {
+ "fieldIdentifier": {
+ "nodeId": "acb26944-1208-4016-9929-ab8dd0860573",
+ "fieldName": "prompt"
+ },
+ "settings": {
+ "type": "string-field-config",
+ "component": "textarea"
+ },
+ "showDescription": false
+ }
+ },
+ "node-field-SRj6Dn28lm": {
+ "id": "node-field-SRj6Dn28lm",
+ "type": "node-field",
+ "parentId": "container-XSINSu999B",
+ "data": {
+ "fieldIdentifier": {
+ "nodeId": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "fieldName": "model"
+ },
+ "showDescription": false
+ }
+ },
+ "heading-N0TXlsboP5": {
+ "id": "heading-N0TXlsboP5",
+ "parentId": "container-XSINSu999B",
+ "type": "heading",
+ "data": { "content": "Text to Image - CogView4" }
+ },
+ "text-PVw8AvXCTz": {
+ "id": "text-PVw8AvXCTz",
+ "parentId": "container-XSINSu999B",
+ "type": "text",
+ "data": { "content": "Generate an image from a prompt with CogView4." }
+ },
+ "divider-5wmCOm9mqG": {
+ "id": "divider-5wmCOm9mqG",
+ "parentId": "container-XSINSu999B",
+ "type": "divider"
+ }
+ },
+ "rootElementId": "container-XSINSu999B"
+ },
+ "nodes": [
+ {
+ "id": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "type": "invocation",
+ "data": {
+ "id": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "cogview4_model_loader",
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": { "x": -52.193850056888095, "y": 282.4721422789611 }
+ },
+ {
+ "id": "a4569d8b-6a43-44b9-8919-4ceec6682904",
+ "type": "invocation",
+ "data": {
+ "id": "a4569d8b-6a43-44b9-8919-4ceec6682904",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "cogview4_text_encoder",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "Positive Prompt",
+ "description": "",
+ "value": "A whimsical stuffed gnome sits on a golden sandy beach, its plush fabric slightly textured and well-worn. The gnome has a round, cheerful face with a fluffy white beard, a bulbous nose, and a tall, slightly floppy red hat with a few decorative stitching details. It wears a tiny blue vest over a soft, earthy-toned tunic, and its stubby arms grasp a ripe yellow banana with a few brown speckles. The ocean waves gently roll onto the shore in the background, with turquoise water reflecting the warm glow of the late afternoon sun. A few scattered seashells and driftwood pieces are near the gnome, while a colorful beach umbrella and footprints in the sand hint at a lively beach scene. The sky is a soft pastel blend of pink, orange, and light blue, with wispy clouds stretching across the horizon.\n"
+ },
+ "glm_encoder": {
+ "name": "glm_encoder",
+ "label": "",
+ "description": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": { "x": 328.9380683664592, "y": 305.11768986950995 }
+ },
+ {
+ "id": "acb26944-1208-4016-9929-ab8dd0860573",
+ "type": "invocation",
+ "data": {
+ "id": "acb26944-1208-4016-9929-ab8dd0860573",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "cogview4_text_encoder",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "Negative Prompt",
+ "description": "",
+ "value": ""
+ },
+ "glm_encoder": {
+ "name": "glm_encoder",
+ "label": "",
+ "description": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": { "x": 334.6799782744916, "y": 496.5882067536601 }
+ },
+ {
+ "id": "cdd72700-463d-4e10-8d76-3e842e4c0b49",
+ "type": "invocation",
+ "data": {
+ "id": "cdd72700-463d-4e10-8d76-3e842e4c0b49",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "cogview4_l2i",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": "",
+ "description": "",
+ "value": "auto"
+ },
+ "metadata": { "name": "metadata", "label": "", "description": "" },
+ "latents": { "name": "latents", "label": "", "description": "" },
+ "vae": { "name": "vae", "label": "", "description": "" }
+ },
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true
+ },
+ "position": { "x": 1112.027247217991, "y": 294.1351498145327 }
+ },
+ {
+ "id": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "type": "invocation",
+ "data": {
+ "id": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "version": "1.0.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "cogview4_denoise",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": "",
+ "description": "",
+ "value": "auto"
+ },
+ "metadata": { "name": "metadata", "label": "", "description": "" },
+ "latents": { "name": "latents", "label": "", "description": "" },
+ "denoise_mask": {
+ "name": "denoise_mask",
+ "label": "",
+ "description": ""
+ },
+ "denoising_start": {
+ "name": "denoising_start",
+ "label": "",
+ "description": "",
+ "value": 0
+ },
+ "denoising_end": {
+ "name": "denoising_end",
+ "label": "",
+ "description": "",
+ "value": 1
+ },
+ "transformer": {
+ "name": "transformer",
+ "label": "",
+ "description": ""
+ },
+ "positive_conditioning": {
+ "name": "positive_conditioning",
+ "label": "",
+ "description": ""
+ },
+ "negative_conditioning": {
+ "name": "negative_conditioning",
+ "label": "",
+ "description": ""
+ },
+ "cfg_scale": {
+ "name": "cfg_scale",
+ "label": "",
+ "description": "",
+ "value": 3.5
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "description": "",
+ "value": 1024
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "description": "",
+ "value": 1024
+ },
+ "steps": {
+ "name": "steps",
+ "label": "",
+ "description": "",
+ "value": 30
+ },
+ "seed": { "name": "seed", "label": "", "description": "", "value": 0 }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false
+ },
+ "position": { "x": 720.8830004638692, "y": 332.66609681908415 }
+ }
+ ],
+ "edges": [
+ {
+ "id": "reactflow__edge-7890507c-d346-4d13-bcb4-bc6d4850b2e3vae-cdd72700-463d-4e10-8d76-3e842e4c0b49vae",
+ "type": "default",
+ "source": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "target": "cdd72700-463d-4e10-8d76-3e842e4c0b49",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-7890507c-d346-4d13-bcb4-bc6d4850b2e3glm_encoder-a4569d8b-6a43-44b9-8919-4ceec6682904glm_encoder",
+ "type": "default",
+ "source": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "target": "a4569d8b-6a43-44b9-8919-4ceec6682904",
+ "sourceHandle": "glm_encoder",
+ "targetHandle": "glm_encoder"
+ },
+ {
+ "id": "reactflow__edge-7890507c-d346-4d13-bcb4-bc6d4850b2e3glm_encoder-acb26944-1208-4016-9929-ab8dd0860573glm_encoder",
+ "type": "default",
+ "source": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "target": "acb26944-1208-4016-9929-ab8dd0860573",
+ "sourceHandle": "glm_encoder",
+ "targetHandle": "glm_encoder"
+ },
+ {
+ "id": "reactflow__edge-a4569d8b-6a43-44b9-8919-4ceec6682904conditioning-e75e2ced-284e-4135-81dc-cdf06c7a409dpositive_conditioning",
+ "type": "default",
+ "source": "a4569d8b-6a43-44b9-8919-4ceec6682904",
+ "target": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "reactflow__edge-acb26944-1208-4016-9929-ab8dd0860573conditioning-e75e2ced-284e-4135-81dc-cdf06c7a409dnegative_conditioning",
+ "type": "default",
+ "source": "acb26944-1208-4016-9929-ab8dd0860573",
+ "target": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "reactflow__edge-e75e2ced-284e-4135-81dc-cdf06c7a409dlatents-cdd72700-463d-4e10-8d76-3e842e4c0b49latents",
+ "type": "default",
+ "source": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "target": "cdd72700-463d-4e10-8d76-3e842e4c0b49",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-7890507c-d346-4d13-bcb4-bc6d4850b2e3transformer-e75e2ced-284e-4135-81dc-cdf06c7a409dtransformer",
+ "type": "default",
+ "source": "7890507c-d346-4d13-bcb4-bc6d4850b2e3",
+ "target": "e75e2ced-284e-4135-81dc-cdf06c7a409d",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json
index dd98eca18f3..8589b301836 100644
--- a/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json
+++ b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json
@@ -1,10 +1,11 @@
{
- "name": "ESRGAN Upscaling with Canny ControlNet",
+ "id": "default_686bb1d0-d086-4c70-9fa3-2f600b922023",
+ "name": "Upscaler - SD1.5, ESRGAN",
"author": "InvokeAI",
- "description": "Sample workflow for using Upscaling with ControlNet with SD1.5",
- "version": "2.0.0",
+ "description": "Sample workflow for using ESRGAN to upscale with ControlNet with SD1.5",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "upscale, controlnet, default",
+ "tags": "sd1.5, upscaling, control",
"notes": "",
"exposedFields": [
{
@@ -36,14 +37,13 @@
"version": "3.0.0",
"category": "default"
},
- "id": "0e71a27e-a22b-4a9b-b20a-6d789abff2bc",
"nodes": [
{
- "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
+ "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
"type": "invocation",
"data": {
- "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
- "version": "1.1.1",
+ "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
+ "version": "1.2.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -57,6 +57,10 @@
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -65,101 +69,148 @@
},
"position": {
"x": 1250,
- "y": 1500
+ "y": 1200
}
},
{
- "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
+ "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
"type": "invocation",
"data": {
- "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
- "version": "1.0.2",
+ "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
+ "version": "1.1.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "main_model_loader",
+ "type": "i2l",
"inputs": {
- "model": {
- "name": "model",
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
"label": "",
- "value": {
- "key": "5cd43ca0-dd0a-418d-9f7e-35b2b9d5e106",
- "hash": "blake3:6987f323017f597213cc3264250edf57056d21a40a0a85d83a1a33a7d44dc41a",
- "name": "Deliberate_v5",
- "base": "sd-1",
- "type": "main"
- }
+ "value": false
}
},
- "isOpen": true,
+ "isOpen": false,
"isIntermediate": true,
"useCache": true
},
"position": {
- "x": 700,
- "y": 1375
+ "x": 1650,
+ "y": 1675
}
},
{
- "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
+ "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
"type": "invocation",
"data": {
- "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
- "version": "1.0.2",
+ "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
+ "version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "image",
+ "type": "l2i",
"inputs": {
- "image": {
- "name": "image",
- "label": "Image To Upscale",
- "value": {
- "image_name": "d2e42ba6-d420-496b-82db-91c9b75956c1.png"
- }
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
}
},
"isOpen": true,
- "isIntermediate": true,
+ "isIntermediate": false,
"useCache": true
},
"position": {
- "x": 344.5593065887157,
- "y": 1698.161491368619
+ "x": 2559.4751127537957,
+ "y": 1246.6000376741406
}
},
{
- "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
+ "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
"type": "invocation",
"data": {
- "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
- "version": "1.3.2",
+ "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
+ "version": "1.1.2",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "esrgan",
+ "type": "controlnet",
"inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
"image": {
"name": "image",
"label": ""
},
- "model_name": {
- "name": "model_name",
- "label": "Upscaler Model",
- "value": "RealESRGAN_x2plus.pth"
+ "control_model": {
+ "name": "control_model",
+ "label": "Control Model (select Canny)"
},
- "tile_size": {
- "name": "tile_size",
+ "control_weight": {
+ "name": "control_weight",
"label": "",
- "value": 400
+ "value": 0.95
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0.1
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.9
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
}
},
"isOpen": true,
@@ -167,8 +218,8 @@
"useCache": true
},
"position": {
- "x": 717.3863693661265,
- "y": 1721.9215053134815
+ "x": 1624.7980608333519,
+ "y": 1902.9649340196056
}
},
{
@@ -176,7 +227,7 @@
"type": "invocation",
"data": {
"id": "1d887701-df21-4966-ae6e-a7d82307d7bd",
- "version": "1.3.2",
+ "version": "1.3.3",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -225,55 +276,121 @@
}
},
{
- "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
+ "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
"type": "invocation",
"data": {
- "id": "ca1d020c-89a8-4958-880a-016d28775cfa",
- "version": "1.1.1",
+ "id": "d8ace142-c05f-4f1d-8982-88dc7473958d",
+ "version": "1.0.3",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "controlnet",
+ "type": "main_model_loader",
"inputs": {
- "image": {
- "name": "image",
+ "model": {
+ "name": "model",
"label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 700,
+ "y": 1375
+ }
+ },
+ {
+ "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
+ "type": "invocation",
+ "data": {
+ "id": "e8bf67fe-67de-4227-87eb-79e86afdfc74",
+ "version": "1.2.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "compel",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
},
- "control_model": {
- "name": "control_model",
- "label": "Control Model (select Canny)",
- "value": {
- "key": "a7b9c76f-4bc5-42aa-b918-c1c458a5bb24",
- "hash": "blake3:260c7f8e10aefea9868cfc68d89970e91033bd37132b14b903e70ee05ebf530e",
- "name": "sd-controlnet-canny",
- "base": "sd-1",
- "type": "controlnet"
- }
+ "clip": {
+ "name": "clip",
+ "label": ""
},
- "control_weight": {
- "name": "control_weight",
- "label": "",
- "value": 0.95
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 1250,
+ "y": 1500
+ }
+ },
+ {
+ "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
+ "type": "invocation",
+ "data": {
+ "id": "771bdf6a-0813-4099-a5d8-921a138754d4",
+ "version": "1.0.2",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "image",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": "Image To Upscale"
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 344.5593065887157,
+ "y": 1698.161491368619
+ }
+ },
+ {
+ "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
+ "type": "invocation",
+ "data": {
+ "id": "f7564dd2-9539-47f2-ac13-190804461f4e",
+ "version": "1.3.2",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "esrgan",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
},
- "begin_step_percent": {
- "name": "begin_step_percent",
- "label": "",
- "value": 0.1
+ "metadata": {
+ "name": "metadata",
+ "label": ""
},
- "end_step_percent": {
- "name": "end_step_percent",
- "label": "",
- "value": 0.9
+ "image": {
+ "name": "image",
+ "label": ""
},
- "control_mode": {
- "name": "control_mode",
- "label": "",
- "value": "balanced"
+ "model_name": {
+ "name": "model_name",
+ "label": "Upscaler Model",
+ "value": "RealESRGAN_x2plus.pth"
},
- "resize_mode": {
- "name": "resize_mode",
+ "tile_size": {
+ "name": "tile_size",
"label": "",
- "value": "just_resize"
+ "value": 400
}
},
"isOpen": true,
@@ -281,8 +398,8 @@
"useCache": true
},
"position": {
- "x": 1624.7980608333519,
- "y": 1902.9649340196056
+ "x": 717.3863693661265,
+ "y": 1721.9215053134815
}
},
{
@@ -413,122 +530,6 @@
"y": 1232.6219060454753
}
},
- {
- "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "type": "invocation",
- "data": {
- "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": true,
- "isIntermediate": false,
- "useCache": true
- },
- "position": {
- "x": 2559.4751127537957,
- "y": 1246.6000376741406
- }
- },
- {
- "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "type": "invocation",
- "data": {
- "id": "5ca498a4-c8c8-4580-a396-0c984317205d",
- "version": "1.0.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "i2l",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": false,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 1650,
- "y": 1675
- }
- },
- {
- "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "type": "invocation",
- "data": {
- "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b",
- "version": "1.1.1",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "compel",
- "inputs": {
- "prompt": {
- "name": "prompt",
- "label": "",
- "value": ""
- },
- "clip": {
- "name": "clip",
- "label": ""
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 1250,
- "y": 1200
- }
- },
{
"id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35",
"type": "invocation",
@@ -834,4 +835,4 @@
"targetHandle": "image_resolution"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json
new file mode 100644
index 00000000000..741e1782dc4
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json
@@ -0,0 +1,388 @@
+{
+ "id": "default_cbf0e034-7b54-4b2c-b670-3b1e2e4b4a88",
+ "name": "Image to Image - FLUX",
+ "author": "InvokeAI",
+ "description": "A simple image-to-image workflow using a FLUX dev model. ",
+ "version": "1.1.0",
+ "contact": "",
+ "tags": "flux, image to image",
+ "notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend using FLUX dev models for image-to-image workflows. The image-to-image performance with FLUX schnell models is poor.",
+ "exposedFields": [
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "t5_encoder_model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "clip_embed_model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "fieldName": "image"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "type": "invocation",
+ "data": {
+ "id": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "type": "flux_denoise",
+ "version": "3.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "denoise_mask": {
+ "name": "denoise_mask",
+ "label": ""
+ },
+ "denoising_start": {
+ "name": "denoising_start",
+ "label": "",
+ "value": 0.04
+ },
+ "denoising_end": {
+ "name": "denoising_end",
+ "label": "",
+ "value": 1
+ },
+ "transformer": {
+ "name": "transformer",
+ "label": ""
+ },
+ "positive_text_conditioning": {
+ "name": "positive_text_conditioning",
+ "label": ""
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 1024
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 1024
+ },
+ "num_steps": {
+ "name": "num_steps",
+ "label": "",
+ "value": 30
+ },
+ "guidance": {
+ "name": "guidance",
+ "label": "",
+ "value": 4
+ },
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 0
+ }
+ }
+ },
+ "position": {
+ "x": 1176.8139201354052,
+ "y": -244.36724863022368
+ }
+ },
+ {
+ "id": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "type": "invocation",
+ "data": {
+ "id": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "type": "flux_vae_encode",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 732.7680166609682,
+ "y": -24.37398171806909
+ }
+ },
+ {
+ "id": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "type": "invocation",
+ "data": {
+ "id": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "type": "flux_vae_decode",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 1575.5797431839133,
+ "y": -209.00150975507415
+ }
+ },
+ {
+ "id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "type": "invocation",
+ "data": {
+ "id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "type": "flux_model_loader",
+ "version": "1.0.4",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": "Model (dev variant recommended for Image-to-Image)"
+ },
+ "t5_encoder_model": {
+ "name": "t5_encoder_model",
+ "label": ""
+ },
+ "clip_embed_model": {
+ "name": "clip_embed_model",
+ "label": ""
+ },
+ "vae_model": {
+ "name": "vae_model",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 328.1809894659957,
+ "y": -90.2241133566946
+ }
+ },
+ {
+ "id": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "type": "invocation",
+ "data": {
+ "id": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "type": "flux_text_encoder",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "t5_encoder": {
+ "name": "t5_encoder",
+ "label": ""
+ },
+ "t5_max_seq_len": {
+ "name": "t5_max_seq_len",
+ "label": "T5 Max Seq Len",
+ "value": 256
+ },
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": "a cat wearing a birthday hat"
+ }
+ }
+ },
+ "position": {
+ "x": 745.8823365057267,
+ "y": -299.60249175851914
+ }
+ },
+ {
+ "id": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "type": "invocation",
+ "data": {
+ "id": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ }
+ },
+ "position": {
+ "x": 750.4061458984118,
+ "y": 279.2179215371294
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "reactflow__edge-cd367e62-2b45-4118-b4ba-7c33e2e0b370latents-7e5172eb-48c1-44db-a770-8fd83e1435d1latents",
+ "type": "default",
+ "source": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "target": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-4754c534-a5f3-4ad0-9382-7887985e668cvalue-cd367e62-2b45-4118-b4ba-7c33e2e0b370seed",
+ "type": "default",
+ "source": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "reactflow__edge-2981a67c-480f-4237-9384-26b68dbf912bheight-cd367e62-2b45-4118-b4ba-7c33e2e0b370height",
+ "type": "default",
+ "source": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "height",
+ "targetHandle": "height"
+ },
+ {
+ "id": "reactflow__edge-2981a67c-480f-4237-9384-26b68dbf912bwidth-cd367e62-2b45-4118-b4ba-7c33e2e0b370width",
+ "type": "default",
+ "source": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "width",
+ "targetHandle": "width"
+ },
+ {
+ "id": "reactflow__edge-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cconditioning-cd367e62-2b45-4118-b4ba-7c33e2e0b370positive_text_conditioning",
+ "type": "default",
+ "source": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_text_conditioning"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90transformer-cd367e62-2b45-4118-b4ba-7c33e2e0b370transformer",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "reactflow__edge-2981a67c-480f-4237-9384-26b68dbf912blatents-cd367e62-2b45-4118-b4ba-7c33e2e0b370latents",
+ "type": "default",
+ "source": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "target": "cd367e62-2b45-4118-b4ba-7c33e2e0b370",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90vae-2981a67c-480f-4237-9384-26b68dbf912bvae",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "2981a67c-480f-4237-9384-26b68dbf912b",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90vae-7e5172eb-48c1-44db-a770-8fd83e1435d1vae",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90max_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "max_seq_len",
+ "targetHandle": "t5_max_seq_len"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90t5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "t5_encoder",
+ "targetHandle": "t5_encoder"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90clip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json b/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json
index 8c7dcee30c8..1e7753ea2c3 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json
+++ b/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json
@@ -1,10 +1,11 @@
{
- "name": "Face Detailer with IP-Adapter & Canny (See Note in Details)",
+ "id": "default_dec5a2e9-f59c-40d9-8869-a056751d79b8",
+ "name": "Face Detailer - SD1.5",
"author": "kosmoskatten",
"description": "A workflow to add detail to and improve faces. This workflow is most effective when used with a model that creates realistic outputs. ",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "face detailer, IP-Adapter, Canny",
+ "tags": "sd1.5, reference image, control",
"notes": "Set this image as the blur mask: https://i.imgur.com/Gxi61zP.png",
"exposedFields": [
{
@@ -37,16 +38,335 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
+ {
+ "id": "c6359181-6479-40ec-bf3a-b7e8451683b8",
+ "type": "invocation",
+ "data": {
+ "id": "c6359181-6479-40ec-bf3a-b7e8451683b8",
+ "version": "1.0.3",
+ "label": "",
+ "notes": "",
+ "type": "main_model_loader",
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 2031.5518710051792,
+ "y": -492.1742944307074
+ }
+ },
+ {
+ "id": "8fe598c6-d447-44fa-a165-4975af77d080",
+ "type": "invocation",
+ "data": {
+ "id": "8fe598c6-d447-44fa-a165-4975af77d080",
+ "version": "1.3.3",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "canny_image_processor",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "detect_resolution": {
+ "name": "detect_resolution",
+ "label": "",
+ "value": 512
+ },
+ "image_resolution": {
+ "name": "image_resolution",
+ "label": "",
+ "value": 512
+ },
+ "low_threshold": {
+ "name": "low_threshold",
+ "label": "",
+ "value": 100
+ },
+ "high_threshold": {
+ "name": "high_threshold",
+ "label": "",
+ "value": 200
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 3519.4131037388597,
+ "y": 576.7946795840575
+ }
+ },
+ {
+ "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
+ "type": "invocation",
+ "data": {
+ "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
+ "version": "1.1.2",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "controlnet",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": "Control Model (select canny)"
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 0.5
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.5
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 3950,
+ "y": 150
+ }
+ },
+ {
+ "id": "22b750db-b85e-486b-b278-ac983e329813",
+ "type": "invocation",
+ "data": {
+ "id": "22b750db-b85e-486b-b278-ac983e329813",
+ "version": "1.4.1",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "ip_adapter",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "ip_adapter_model": {
+ "name": "ip_adapter_model",
+ "label": "IP-Adapter Model (select IP Adapter Face)"
+ },
+ "clip_vision_model": {
+ "name": "clip_vision_model",
+ "label": "",
+ "value": "ViT-H"
+ },
+ "weight": {
+ "name": "weight",
+ "label": "",
+ "value": 0.5
+ },
+ "method": {
+ "name": "method",
+ "label": "",
+ "value": "full"
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.8
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 3575,
+ "y": -200
+ }
+ },
+ {
+ "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
+ "type": "invocation",
+ "data": {
+ "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
+ "version": "1.2.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "compel",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 2550,
+ "y": -525
+ }
+ },
+ {
+ "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
+ "type": "invocation",
+ "data": {
+ "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
+ "version": "1.3.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "l2i",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": true
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true
+ },
+ "position": {
+ "x": 4980.1395106966565,
+ "y": -255.9158921745602
+ }
+ },
+ {
+ "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
+ "type": "invocation",
+ "data": {
+ "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
+ "version": "1.1.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "i2l",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": true
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 3100,
+ "y": -275
+ }
+ },
{
"id": "44f2c190-eb03-460d-8d11-a94d13b33f19",
"type": "invocation",
"data": {
"id": "44f2c190-eb03-460d-8d11-a94d13b33f19",
- "version": "1.1.1",
+ "version": "1.2.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -60,6 +380,10 @@
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -235,50 +559,11 @@
"name": "height",
"label": "",
"value": 512
- },
- "resample_mode": {
- "name": "resample_mode",
- "label": "",
- "value": "lanczos"
- }
- },
- "isOpen": false,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 3000,
- "y": 0
- }
- },
- {
- "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "type": "invocation",
- "data": {
- "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534",
- "version": "1.0.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "i2l",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
+ },
+ "resample_mode": {
+ "name": "resample_mode",
"label": "",
- "value": true
+ "value": "lanczos"
}
},
"isOpen": false,
@@ -286,8 +571,8 @@
"useCache": true
},
"position": {
- "x": 3100,
- "y": -275
+ "x": 3000,
+ "y": 0
}
},
{
@@ -418,53 +703,6 @@
"y": -175
}
},
- {
- "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "type": "invocation",
- "data": {
- "id": "2224ed72-2453-4252-bd89-3085240e0b6f",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": true
- }
- },
- "isOpen": true,
- "isIntermediate": false,
- "useCache": true
- },
- "position": {
- "x": 4980.1395106966565,
- "y": -255.9158921745602
- }
- },
{
"id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323",
"type": "invocation",
@@ -692,201 +930,6 @@
"y": -275
}
},
- {
- "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "type": "invocation",
- "data": {
- "id": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65",
- "version": "1.1.1",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "compel",
- "inputs": {
- "prompt": {
- "name": "prompt",
- "label": "",
- "value": ""
- },
- "clip": {
- "name": "clip",
- "label": ""
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 2550,
- "y": -525
- }
- },
- {
- "id": "22b750db-b85e-486b-b278-ac983e329813",
- "type": "invocation",
- "data": {
- "id": "22b750db-b85e-486b-b278-ac983e329813",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "ip_adapter",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "ip_adapter_model": {
- "name": "ip_adapter_model",
- "label": "IP-Adapter Model (select IP Adapter Face)",
- "value": {
- "key": "1cc210bb-4d0a-4312-b36c-b5d46c43768e",
- "hash": "blake3:3d669dffa7471b357b4df088b99ffb6bf4d4383d5e0ef1de5ec1c89728a3d5a5",
- "name": "ip_adapter_sd15",
- "base": "sd-1",
- "type": "ip_adapter"
- }
- },
- "weight": {
- "name": "weight",
- "label": "",
- "value": 0.5
- },
- "begin_step_percent": {
- "name": "begin_step_percent",
- "label": "",
- "value": 0
- },
- "end_step_percent": {
- "name": "end_step_percent",
- "label": "",
- "value": 0.8
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 3575,
- "y": -200
- }
- },
- {
- "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "type": "invocation",
- "data": {
- "id": "f60b6161-8f26-42f6-89ff-545e6011e501",
- "version": "1.1.1",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "controlnet",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "control_model": {
- "name": "control_model",
- "label": "Control Model (select canny)",
- "value": {
- "key": "5bdaacf7-a7a3-4fb8-b394-cc0ffbb8941d",
- "hash": "blake3:260c7f8e10aefea9868cfc68d89970e91033bd37132b14b903e70ee05ebf530e",
- "name": "sd-controlnet-canny",
- "base": "sd-1",
- "type": "controlnet"
- }
- },
- "control_weight": {
- "name": "control_weight",
- "label": "",
- "value": 0.5
- },
- "begin_step_percent": {
- "name": "begin_step_percent",
- "label": "",
- "value": 0
- },
- "end_step_percent": {
- "name": "end_step_percent",
- "label": "",
- "value": 0.5
- },
- "control_mode": {
- "name": "control_mode",
- "label": "",
- "value": "balanced"
- },
- "resize_mode": {
- "name": "resize_mode",
- "label": "",
- "value": "just_resize"
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 3950,
- "y": 150
- }
- },
- {
- "id": "8fe598c6-d447-44fa-a165-4975af77d080",
- "type": "invocation",
- "data": {
- "id": "8fe598c6-d447-44fa-a165-4975af77d080",
- "version": "1.3.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "canny_image_processor",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "image": {
- "name": "image",
- "label": ""
- },
- "detect_resolution": {
- "name": "detect_resolution",
- "label": "",
- "value": 512
- },
- "image_resolution": {
- "name": "image_resolution",
- "label": "",
- "value": 512
- },
- "low_threshold": {
- "name": "low_threshold",
- "label": "",
- "value": 100
- },
- "high_threshold": {
- "name": "high_threshold",
- "label": "",
- "value": 200
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 3519.4131037388597,
- "y": 576.7946795840575
- }
- },
{
"id": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a",
"type": "invocation",
@@ -1035,30 +1078,6 @@
"x": 2578.2364832140506,
"y": 78.7948456497351
}
- },
- {
- "id": "c6359181-6479-40ec-bf3a-b7e8451683b8",
- "type": "invocation",
- "data": {
- "id": "c6359181-6479-40ec-bf3a-b7e8451683b8",
- "version": "1.0.2",
- "label": "",
- "notes": "",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "name": "model",
- "label": ""
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 2031.5518710051792,
- "y": -492.1742944307074
- }
}
],
"edges": [
@@ -1413,4 +1432,4 @@
"targetHandle": "vae"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json
new file mode 100644
index 00000000000..ef4575813bd
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json
@@ -0,0 +1,324 @@
+{
+ "id": "default_444fe292-896b-44fd-bfc6-c0b5d220fffc",
+ "name": "Text to Image - FLUX",
+ "author": "InvokeAI",
+ "description": "A simple text-to-image workflow using FLUX dev or schnell models.",
+ "version": "1.1.0",
+ "contact": "",
+ "tags": "flux, text to image",
+ "notes": "Prerequisite model downloads: T5 Encoder, CLIP-L Encoder, and FLUX VAE. Quantized and un-quantized versions can be found in the starter models tab within your Model Manager. We recommend 4 steps for FLUX schnell models and 30 steps for FLUX dev models.",
+ "exposedFields": [
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "t5_encoder_model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "clip_embed_model"
+ },
+ {
+ "nodeId": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "fieldName": "prompt"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "type": "invocation",
+ "data": {
+ "id": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "type": "flux_denoise",
+ "version": "3.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "denoise_mask": {
+ "name": "denoise_mask",
+ "label": "Denoise Mask"
+ },
+ "denoising_start": {
+ "name": "denoising_start",
+ "label": "",
+ "value": 0
+ },
+ "denoising_end": {
+ "name": "denoising_end",
+ "label": "",
+ "value": 1
+ },
+ "transformer": {
+ "name": "transformer",
+ "label": ""
+ },
+ "positive_text_conditioning": {
+ "name": "positive_text_conditioning",
+ "label": ""
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 1024
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 1024
+ },
+ "num_steps": {
+ "name": "num_steps",
+ "label": "",
+ "value": 4
+ },
+ "guidance": {
+ "name": "guidance",
+ "label": "",
+ "value": 4
+ },
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 0
+ }
+ }
+ },
+ "position": {
+ "x": 1180.8001377784371,
+ "y": -219.96908055568326
+ }
+ },
+ {
+ "id": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "type": "invocation",
+ "data": {
+ "id": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "type": "flux_vae_decode",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 1575.5797431839133,
+ "y": -209.00150975507415
+ }
+ },
+ {
+ "id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "type": "invocation",
+ "data": {
+ "id": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "type": "flux_model_loader",
+ "version": "1.0.4",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ },
+ "t5_encoder_model": {
+ "name": "t5_encoder_model",
+ "label": ""
+ },
+ "clip_embed_model": {
+ "name": "clip_embed_model",
+ "label": ""
+ },
+ "vae_model": {
+ "name": "vae_model",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 381.1882713063478,
+ "y": -95.89663532854017
+ }
+ },
+ {
+ "id": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "type": "invocation",
+ "data": {
+ "id": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "type": "flux_text_encoder",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "t5_encoder": {
+ "name": "t5_encoder",
+ "label": ""
+ },
+ "t5_max_seq_len": {
+ "name": "t5_max_seq_len",
+ "label": "T5 Max Seq Len",
+ "value": 256
+ },
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": "a cat"
+ }
+ }
+ },
+ "position": {
+ "x": 778.4899149328337,
+ "y": -100.36469216659502
+ }
+ },
+ {
+ "id": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "type": "invocation",
+ "data": {
+ "id": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ }
+ },
+ "position": {
+ "x": 800.9667463219505,
+ "y": 285.8297267547506
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "reactflow__edge-0940bc54-21fb-4346-bc68-fca5724c2747latents-7e5172eb-48c1-44db-a770-8fd83e1435d1latents",
+ "type": "default",
+ "source": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "target": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-4754c534-a5f3-4ad0-9382-7887985e668cvalue-0940bc54-21fb-4346-bc68-fca5724c2747seed",
+ "type": "default",
+ "source": "4754c534-a5f3-4ad0-9382-7887985e668c",
+ "target": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "reactflow__edge-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cconditioning-0940bc54-21fb-4346-bc68-fca5724c2747positive_text_conditioning",
+ "type": "default",
+ "source": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "target": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_text_conditioning"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90transformer-0940bc54-21fb-4346-bc68-fca5724c2747transformer",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "0940bc54-21fb-4346-bc68-fca5724c2747",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90vae-7e5172eb-48c1-44db-a770-8fd83e1435d1vae",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "7e5172eb-48c1-44db-a770-8fd83e1435d1",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90max_seq_len-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_max_seq_len",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "max_seq_len",
+ "targetHandle": "t5_max_seq_len"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90t5_encoder-01f674f8-b3d1-4df1-acac-6cb8e0bfb63ct5_encoder",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "t5_encoder",
+ "targetHandle": "t5_encoder"
+ },
+ {
+ "id": "reactflow__edge-f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90clip-01f674f8-b3d1-4df1-acac-6cb8e0bfb63cclip",
+ "type": "default",
+ "source": "f8d9d7c8-9ed7-4bd7-9e42-ab0e89bfac90",
+ "target": "01f674f8-b3d1-4df1-acac-6cb8e0bfb63c",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json b/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json
index d859094216e..1c91f4a7b0c 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json
+++ b/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json
@@ -1,10 +1,11 @@
{
- "name": "Multi ControlNet (Canny & Depth)",
+ "id": "default_2d05e719-a6b9-4e64-9310-b875d3b2f9d2",
+ "name": "Text to Image - SD1.5, Control",
"author": "InvokeAI",
"description": "A sample workflow using canny & depth ControlNets to guide the generation process. ",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "ControlNet, canny, depth",
+ "tags": "sd1.5, control, text to image",
"notes": "",
"exposedFields": [
{
@@ -37,24 +38,104 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
{
- "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
+ "id": "9db25398-c869-4a63-8815-c6559341ef12",
"type": "invocation",
"data": {
- "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
- "version": "1.0.2",
+ "id": "9db25398-c869-4a63-8815-c6559341ef12",
+ "version": "1.3.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "image",
+ "type": "l2i",
"inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true
+ },
+ "position": {
+ "x": 5675,
+ "y": -825
+ }
+ },
+ {
+ "id": "c826ba5e-9676-4475-b260-07b85e88753c",
+ "type": "invocation",
+ "data": {
+ "id": "c826ba5e-9676-4475-b260-07b85e88753c",
+ "version": "1.3.3",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "canny_image_processor",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
"image": {
"name": "image",
- "label": "Depth Input Image"
+ "label": ""
+ },
+ "detect_resolution": {
+ "name": "detect_resolution",
+ "label": "",
+ "value": 512
+ },
+ "image_resolution": {
+ "name": "image_resolution",
+ "label": "",
+ "value": 512
+ },
+ "low_threshold": {
+ "name": "low_threshold",
+ "label": "",
+ "value": 100
+ },
+ "high_threshold": {
+ "name": "high_threshold",
+ "label": "",
+ "value": 200
}
},
"isOpen": true,
@@ -62,16 +143,69 @@
"useCache": true
},
"position": {
- "x": 3666.135718057363,
- "y": 186.66887319822808
+ "x": 4095.757337055795,
+ "y": -455.63440891935863
}
},
{
- "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
+ "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
"type": "invocation",
"data": {
- "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
- "version": "1.1.1",
+ "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
+ "version": "1.2.4",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "midas_depth_image_processor",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "a_mult": {
+ "name": "a_mult",
+ "label": "",
+ "value": 2
+ },
+ "bg_th": {
+ "name": "bg_th",
+ "label": "",
+ "value": 0.1
+ },
+ "detect_resolution": {
+ "name": "detect_resolution",
+ "label": "",
+ "value": 512
+ },
+ "image_resolution": {
+ "name": "image_resolution",
+ "label": "",
+ "value": 512
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 4082.783145980783,
+ "y": 0.01629251229994111
+ }
+ },
+ {
+ "id": "d204d184-f209-4fae-a0a1-d152800844e1",
+ "type": "invocation",
+ "data": {
+ "id": "d204d184-f209-4fae-a0a1-d152800844e1",
+ "version": "1.1.2",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -83,14 +217,7 @@
},
"control_model": {
"name": "control_model",
- "label": "Control Model (select depth)",
- "value": {
- "key": "87e8855c-671f-4c9e-bbbb-8ed47ccb4aac",
- "hash": "blake3:2550bf22a53942dfa28ab2fed9d10d80851112531f44d977168992edf9d0534c",
- "name": "control_v11f1p_sd15_depth",
- "base": "sd-1",
- "type": "controlnet"
- }
+ "label": "Control Model (select canny)"
},
"control_weight": {
"name": "control_weight",
@@ -123,16 +250,16 @@
"useCache": true
},
"position": {
- "x": 4477.604342844504,
- "y": -49.39005411272677
+ "x": 4479.68542130465,
+ "y": -618.4221638099414
}
},
{
- "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
+ "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
"type": "invocation",
"data": {
- "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
- "version": "1.1.1",
+ "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
+ "version": "1.2.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -140,12 +267,16 @@
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Negative Prompt",
+ "label": "Positive Prompt",
"value": ""
},
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -154,7 +285,7 @@
},
"position": {
"x": 4075,
- "y": -825
+ "y": -1125
}
},
{
@@ -162,7 +293,7 @@
"type": "invocation",
"data": {
"id": "54486974-835b-4d81-8f82-05f9f32ce9e9",
- "version": "1.0.2",
+ "version": "1.0.3",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -183,11 +314,11 @@
}
},
{
- "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
+ "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
"type": "invocation",
"data": {
- "id": "7ce68934-3419-42d4-ac70-82cfc9397306",
- "version": "1.1.1",
+ "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1",
+ "version": "1.2.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -195,12 +326,16 @@
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Positive Prompt",
+ "label": "Negative Prompt",
"value": ""
},
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -209,15 +344,15 @@
},
"position": {
"x": 4075,
- "y": -1125
+ "y": -825
}
},
{
- "id": "d204d184-f209-4fae-a0a1-d152800844e1",
+ "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
"type": "invocation",
"data": {
- "id": "d204d184-f209-4fae-a0a1-d152800844e1",
- "version": "1.1.1",
+ "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c",
+ "version": "1.1.2",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -229,14 +364,7 @@
},
"control_model": {
"name": "control_model",
- "label": "Control Model (select canny)",
- "value": {
- "key": "5bdaacf7-a7a3-4fb8-b394-cc0ffbb8941d",
- "hash": "blake3:260c7f8e10aefea9868cfc68d89970e91033bd37132b14b903e70ee05ebf530e",
- "name": "sd-controlnet-canny",
- "base": "sd-1",
- "type": "controlnet"
- }
+ "label": "Control Model (select depth)"
},
"control_weight": {
"name": "control_weight",
@@ -269,15 +397,15 @@
"useCache": true
},
"position": {
- "x": 4479.68542130465,
- "y": -618.4221638099414
+ "x": 4477.604342844504,
+ "y": -49.39005411272677
}
},
{
- "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
+ "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
"type": "invocation",
"data": {
- "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
+ "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e",
"version": "1.0.2",
"nodePack": "invokeai",
"label": "",
@@ -286,31 +414,7 @@
"inputs": {
"image": {
"name": "image",
- "label": "Canny Input Image"
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 3625,
- "y": -425
- }
- },
- {
- "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "type": "invocation",
- "data": {
- "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
- "version": "1.0.0",
- "label": "ControlNet Collection",
- "notes": "",
- "type": "collect",
- "inputs": {
- "item": {
- "name": "item",
- "label": ""
+ "label": "Depth Input Image"
}
},
"isOpen": true,
@@ -318,52 +422,24 @@
"useCache": true
},
"position": {
- "x": 4875,
- "y": -575
+ "x": 3666.135718057363,
+ "y": 186.66887319822808
}
},
{
- "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
+ "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
"type": "invocation",
"data": {
- "id": "018b1214-c2af-43a7-9910-fb687c6726d7",
- "version": "1.2.3",
+ "id": "c4b23e64-7986-40c4-9cad-46327b12e204",
+ "version": "1.0.2",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "midas_depth_image_processor",
+ "type": "image",
"inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
"image": {
"name": "image",
- "label": ""
- },
- "a_mult": {
- "name": "a_mult",
- "label": "",
- "value": 2
- },
- "bg_th": {
- "name": "bg_th",
- "label": "",
- "value": 0.1
- },
- "detect_resolution": {
- "name": "detect_resolution",
- "label": "",
- "value": 512
- },
- "image_resolution": {
- "name": "image_resolution",
- "label": "",
- "value": 512
+ "label": "Canny Input Image"
}
},
"isOpen": true,
@@ -371,52 +447,23 @@
"useCache": true
},
"position": {
- "x": 4082.783145980783,
- "y": 0.01629251229994111
+ "x": 3625,
+ "y": -425
}
},
{
- "id": "c826ba5e-9676-4475-b260-07b85e88753c",
+ "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
"type": "invocation",
"data": {
- "id": "c826ba5e-9676-4475-b260-07b85e88753c",
- "version": "1.3.2",
- "nodePack": "invokeai",
- "label": "",
+ "id": "ca4d5059-8bfb-447f-b415-da0faba5a143",
+ "version": "1.0.0",
+ "label": "ControlNet Collection",
"notes": "",
- "type": "canny_image_processor",
+ "type": "collect",
"inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "image": {
- "name": "image",
+ "item": {
+ "name": "item",
"label": ""
- },
- "detect_resolution": {
- "name": "detect_resolution",
- "label": "",
- "value": 512
- },
- "image_resolution": {
- "name": "image_resolution",
- "label": "",
- "value": 512
- },
- "low_threshold": {
- "name": "low_threshold",
- "label": "",
- "value": 100
- },
- "high_threshold": {
- "name": "high_threshold",
- "label": "",
- "value": 200
}
},
"isOpen": true,
@@ -424,55 +471,8 @@
"useCache": true
},
"position": {
- "x": 4095.757337055795,
- "y": -455.63440891935863
- }
- },
- {
- "id": "9db25398-c869-4a63-8815-c6559341ef12",
- "type": "invocation",
- "data": {
- "id": "9db25398-c869-4a63-8815-c6559341ef12",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": true,
- "isIntermediate": false,
- "useCache": true
- },
- "position": {
- "x": 5675,
- "y": -825
+ "x": 4875,
+ "y": -575
}
},
{
@@ -1001,4 +1001,4 @@
"targetHandle": "image_resolution"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json
new file mode 100644
index 00000000000..240dc933bf3
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json
@@ -0,0 +1,1406 @@
+{
+ "id": "default_f96e794f-eb3e-4d01-a960-9b4e43402bcf",
+ "name": "Upscaler - SD1.5, MultiDiffusion",
+ "author": "Invoke",
+ "description": "A workflow to upscale an input image with tiled upscaling, using SD1.5 based models.",
+ "version": "1.0.0",
+ "contact": "invoke@invoke.ai",
+ "tags": "sd1.5, upscaling",
+ "notes": "",
+ "exposedFields": [
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "image"
+ },
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "scale"
+ },
+ {
+ "nodeId": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "fieldName": "board"
+ },
+ {
+ "nodeId": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "fieldName": "a"
+ },
+ {
+ "nodeId": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "fieldName": "a"
+ },
+ {
+ "nodeId": "14469dfe-9f49-4a13-89a7-eb4d45794b2b",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2",
+ "fieldName": "prompt"
+ },
+ {
+ "nodeId": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "image_to_image_model"
+ },
+ {
+ "nodeId": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "fieldName": "model"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2",
+ "type": "invocation",
+ "data": {
+ "id": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2",
+ "type": "compel",
+ "version": "1.2.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "Negative Prompt (Optional)",
+ "value": "blurry painting, art, sketch"
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3550,
+ "y": -2725
+ }
+ },
+ {
+ "id": "14469dfe-9f49-4a13-89a7-eb4d45794b2b",
+ "type": "invocation",
+ "data": {
+ "id": "14469dfe-9f49-4a13-89a7-eb4d45794b2b",
+ "type": "compel",
+ "version": "1.2.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "Positive Prompt (Optional)",
+ "value": "high quality studio lighting, photo"
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3550,
+ "y": -3025
+ }
+ },
+ {
+ "id": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "type": "invocation",
+ "data": {
+ "id": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "type": "main_model_loader",
+ "version": "1.0.3",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -3050
+ }
+ },
+ {
+ "id": "71a116e1-c631-48b3-923d-acea4753b887",
+ "type": "invocation",
+ "data": {
+ "id": "71a116e1-c631-48b3-923d-acea4753b887",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.3
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1550
+ }
+ },
+ {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "type": "invocation",
+ "data": {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.025
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1575
+ }
+ },
+ {
+ "id": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "type": "invocation",
+ "data": {
+ "id": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.45
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1200
+ }
+ },
+ {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "type": "invocation",
+ "data": {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.15
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1225
+ }
+ },
+ {
+ "id": "1ed88043-3519-41d5-a895-07944f03de70",
+ "type": "invocation",
+ "data": {
+ "id": "1ed88043-3519-41d5-a895-07944f03de70",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.3
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1650
+ }
+ },
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "type": "invocation",
+ "data": {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.032
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1850
+ }
+ },
+ {
+ "id": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "type": "invocation",
+ "data": {
+ "id": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "type": "spandrel_image_to_image_autoscale",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": "Image to Upscale"
+ },
+ "image_to_image_model": {
+ "name": "image_to_image_model",
+ "label": ""
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 512
+ },
+ "scale": {
+ "name": "scale",
+ "label": "Scale (2x, 4x, 8x, 16x)",
+ "value": 2
+ },
+ "fit_to_multiple_of_8": {
+ "name": "fit_to_multiple_of_8",
+ "label": "",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -4750,
+ "y": -2125
+ }
+ },
+ {
+ "id": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "type": "invocation",
+ "data": {
+ "id": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "type": "model_identifier",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": "ControlNet Model - Choose a Tile ControlNet"
+ }
+ }
+ },
+ "position": {
+ "x": -3450,
+ "y": -1450
+ }
+ },
+ {
+ "id": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "type": "invocation",
+ "data": {
+ "id": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2075
+ }
+ },
+ {
+ "id": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "type": "invocation",
+ "data": {
+ "id": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "type": "boolean",
+ "version": "1.0.1",
+ "label": "Tiled Option",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "Tiled VAE (Saves VRAM, Color Inconsistency)",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -2475
+ }
+ },
+ {
+ "id": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "type": "invocation",
+ "data": {
+ "id": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "Creativity Input",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "Creativity Control (-10 to 10)",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": -1
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2350
+ }
+ },
+ {
+ "id": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "type": "invocation",
+ "data": {
+ "id": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "DIV"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 100
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -1975
+ }
+ },
+ {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "type": "invocation",
+ "data": {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "A",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 10
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2075
+ }
+ },
+ {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "type": "invocation",
+ "data": {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 4.99
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2025
+ }
+ },
+ {
+ "id": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "type": "invocation",
+ "data": {
+ "id": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "Structural Input",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "Structural Control (-10 to 10)",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 10
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -2100
+ }
+ },
+ {
+ "id": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "type": "invocation",
+ "data": {
+ "id": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "type": "collect",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "item": {
+ "name": "item",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -2275,
+ "y": -2075
+ }
+ },
+ {
+ "id": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "type": "invocation",
+ "data": {
+ "id": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "type": "controlnet",
+ "version": "1.1.2",
+ "label": "Initial Control (Use Tile)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": ""
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 0.6
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.5
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -1775
+ }
+ },
+ {
+ "id": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "type": "invocation",
+ "data": {
+ "id": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "type": "unsharp_mask",
+ "version": "1.2.2",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "radius": {
+ "name": "radius",
+ "label": "",
+ "value": 2
+ },
+ "strength": {
+ "name": "strength",
+ "label": "",
+ "value": 50
+ }
+ }
+ },
+ "position": {
+ "x": -4400,
+ "y": -2125
+ }
+ },
+ {
+ "id": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "type": "invocation",
+ "data": {
+ "id": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "type": "i2l",
+ "version": "1.1.0",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2125
+ }
+ },
+ {
+ "id": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "type": "invocation",
+ "data": {
+ "id": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "type": "l2i",
+ "version": "1.3.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": "Output Board"
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -2825
+ }
+ },
+ {
+ "id": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "type": "invocation",
+ "data": {
+ "id": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "type": "tiled_multi_diffusion_denoise_latents",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "positive_conditioning": {
+ "name": "positive_conditioning",
+ "label": ""
+ },
+ "negative_conditioning": {
+ "name": "negative_conditioning",
+ "label": ""
+ },
+ "noise": {
+ "name": "noise",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "tile_height": {
+ "name": "tile_height",
+ "label": "",
+ "value": 768
+ },
+ "tile_width": {
+ "name": "tile_width",
+ "label": "",
+ "value": 768
+ },
+ "tile_overlap": {
+ "name": "tile_overlap",
+ "label": "",
+ "value": 128
+ },
+ "steps": {
+ "name": "steps",
+ "label": "",
+ "value": 25
+ },
+ "cfg_scale": {
+ "name": "cfg_scale",
+ "label": "",
+ "value": 5
+ },
+ "denoising_start": {
+ "name": "denoising_start",
+ "label": "",
+ "value": 0.6
+ },
+ "denoising_end": {
+ "name": "denoising_end",
+ "label": "",
+ "value": 1
+ },
+ "scheduler": {
+ "name": "scheduler",
+ "label": "",
+ "value": "kdpm_2"
+ },
+ "unet": {
+ "name": "unet",
+ "label": ""
+ },
+ "cfg_rescale_multiplier": {
+ "name": "cfg_rescale_multiplier",
+ "label": "",
+ "value": 0
+ },
+ "control": {
+ "name": "control",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -2825
+ }
+ },
+ {
+ "id": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "type": "invocation",
+ "data": {
+ "id": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "type": "controlnet",
+ "version": "1.1.2",
+ "label": "Second Phase Control (Use Tile)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": ""
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 0.25
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0.5
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.85
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "Control Mode",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -1325
+ }
+ },
+ {
+ "id": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "type": "invocation",
+ "data": {
+ "id": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "type": "noise",
+ "version": "1.0.2",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 3
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 512
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 512
+ },
+ "use_cpu": {
+ "name": "use_cpu",
+ "label": "",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2025
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "reactflow__edge-009b38e3-4e17-4ac5-958c-14891991ae28vae-117f982a-03da-49b1-bf9f-29711160ac02vae",
+ "type": "default",
+ "source": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-009b38e3-4e17-4ac5-958c-14891991ae28vae-c3b60a50-8039-4924-90e3-8c608e1fecb5vae",
+ "type": "default",
+ "source": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-33fe76a0-5efd-4482-a7f0-e2abf1223dc2conditioning-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7anegative_conditioning",
+ "type": "default",
+ "source": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "reactflow__edge-009b38e3-4e17-4ac5-958c-14891991ae28clip-33fe76a0-5efd-4482-a7f0-e2abf1223dc2clip",
+ "type": "default",
+ "source": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "target": "33fe76a0-5efd-4482-a7f0-e2abf1223dc2",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ },
+ {
+ "id": "reactflow__edge-14469dfe-9f49-4a13-89a7-eb4d45794b2bconditioning-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7apositive_conditioning",
+ "type": "default",
+ "source": "14469dfe-9f49-4a13-89a7-eb4d45794b2b",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "reactflow__edge-009b38e3-4e17-4ac5-958c-14891991ae28clip-14469dfe-9f49-4a13-89a7-eb4d45794b2bclip",
+ "type": "default",
+ "source": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "target": "14469dfe-9f49-4a13-89a7-eb4d45794b2b",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ },
+ {
+ "id": "reactflow__edge-009b38e3-4e17-4ac5-958c-14891991ae28unet-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7aunet",
+ "type": "default",
+ "source": "009b38e3-4e17-4ac5-958c-14891991ae28",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "unet",
+ "targetHandle": "unet"
+ },
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21-75a89685-0f82-40ed-9b88-e583673be9fc-collapsed",
+ "type": "collapsed",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "75a89685-0f82-40ed-9b88-e583673be9fc"
+ },
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21-1ed88043-3519-41d5-a895-07944f03de70-collapsed",
+ "type": "collapsed",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "1ed88043-3519-41d5-a895-07944f03de70"
+ },
+ {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384-c8f5c671-8c87-4d96-a75e-a9937ac6bc03-collapsed",
+ "type": "collapsed",
+ "source": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "target": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03"
+ },
+ {
+ "id": "reactflow__edge-c8f5c671-8c87-4d96-a75e-a9937ac6bc03value-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7adenoising_start",
+ "type": "default",
+ "source": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "value",
+ "targetHandle": "denoising_start"
+ },
+ {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c-49a8cc12-aa19-48c5-b6b3-04e0b603b384-collapsed",
+ "type": "collapsed",
+ "source": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "target": "49a8cc12-aa19-48c5-b6b3-04e0b603b384"
+ },
+ {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc-96e1bcd0-326b-4b67-8b14-239da2440aec-collapsed",
+ "type": "collapsed",
+ "source": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "target": "96e1bcd0-326b-4b67-8b14-239da2440aec"
+ },
+ {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22-71a116e1-c631-48b3-923d-acea4753b887-collapsed",
+ "type": "collapsed",
+ "source": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "target": "71a116e1-c631-48b3-923d-acea4753b887"
+ },
+ {
+ "id": "reactflow__edge-71a116e1-c631-48b3-923d-acea4753b887value-be4082d6-e238-40ea-a9df-fc0d725e8895begin_step_percent",
+ "type": "default",
+ "source": "71a116e1-c631-48b3-923d-acea4753b887",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "value",
+ "targetHandle": "begin_step_percent"
+ },
+ {
+ "id": "reactflow__edge-71a116e1-c631-48b3-923d-acea4753b887value-b78f53b6-2eae-4956-97b4-7e73768d1491end_step_percent",
+ "type": "default",
+ "source": "71a116e1-c631-48b3-923d-acea4753b887",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "value",
+ "targetHandle": "end_step_percent"
+ },
+ {
+ "id": "reactflow__edge-00e2c587-f047-4413-ad15-bd31ea53ce22value-71a116e1-c631-48b3-923d-acea4753b887a",
+ "type": "default",
+ "source": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "target": "71a116e1-c631-48b3-923d-acea4753b887",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-bd094e2f-41e5-4b61-9f7b-56cf337d53favalue-00e2c587-f047-4413-ad15-bd31ea53ce22a",
+ "type": "default",
+ "source": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "target": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "sourceHandle": "value",
+ "targetHandle": "a"
+ },
+ {
+ "id": "reactflow__edge-96e1bcd0-326b-4b67-8b14-239da2440aecvalue-be4082d6-e238-40ea-a9df-fc0d725e8895control_weight",
+ "type": "default",
+ "source": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "value",
+ "targetHandle": "control_weight"
+ },
+ {
+ "id": "reactflow__edge-75a89685-0f82-40ed-9b88-e583673be9fcvalue-96e1bcd0-326b-4b67-8b14-239da2440aeca",
+ "type": "default",
+ "source": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "target": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-9b281506-4079-4a3d-ab40-b386156fcd21value-75a89685-0f82-40ed-9b88-e583673be9fca",
+ "type": "default",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-1ed88043-3519-41d5-a895-07944f03de70value-b78f53b6-2eae-4956-97b4-7e73768d1491control_weight",
+ "type": "default",
+ "source": "1ed88043-3519-41d5-a895-07944f03de70",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "value",
+ "targetHandle": "control_weight"
+ },
+ {
+ "id": "reactflow__edge-9b281506-4079-4a3d-ab40-b386156fcd21value-1ed88043-3519-41d5-a895-07944f03de70a",
+ "type": "default",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "1ed88043-3519-41d5-a895-07944f03de70",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-bd094e2f-41e5-4b61-9f7b-56cf337d53favalue-9b281506-4079-4a3d-ab40-b386156fcd21a",
+ "type": "default",
+ "source": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "target": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "sourceHandle": "value",
+ "targetHandle": "a"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeheight-8923451b-5a27-4395-b7f2-dce875fca6f5height",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "height",
+ "targetHandle": "height"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebewidth-8923451b-5a27-4395-b7f2-dce875fca6f5width",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "width",
+ "targetHandle": "width"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-b78f53b6-2eae-4956-97b4-7e73768d1491image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-be4082d6-e238-40ea-a9df-fc0d725e8895image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-117f982a-03da-49b1-bf9f-29711160ac02image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-011039f6-04cf-4607-8eb1-3304eb819c8cimage-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage",
+ "type": "default",
+ "source": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "target": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-f936ebb3-6902-4df9-a775-6a68bac2da70model-be4082d6-e238-40ea-a9df-fc0d725e8895control_model",
+ "type": "default",
+ "source": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "model",
+ "targetHandle": "control_model"
+ },
+ {
+ "id": "reactflow__edge-f936ebb3-6902-4df9-a775-6a68bac2da70model-b78f53b6-2eae-4956-97b4-7e73768d1491control_model",
+ "type": "default",
+ "source": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "model",
+ "targetHandle": "control_model"
+ },
+ {
+ "id": "reactflow__edge-00239057-20d4-4cd2-a010-28727b256ea2value-8923451b-5a27-4395-b7f2-dce875fca6f5seed",
+ "type": "default",
+ "source": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "reactflow__edge-094bc4ed-5c68-4342-84f4-51056c755796value-c3b60a50-8039-4924-90e3-8c608e1fecb5tiled",
+ "type": "default",
+ "source": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "value",
+ "targetHandle": "tiled"
+ },
+ {
+ "id": "reactflow__edge-094bc4ed-5c68-4342-84f4-51056c755796value-117f982a-03da-49b1-bf9f-29711160ac02tiled",
+ "type": "default",
+ "source": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "value",
+ "targetHandle": "tiled"
+ },
+ {
+ "id": "reactflow__edge-1dd915a3-6756-48ed-b68b-ee3b4bd06c1dvalue-14e65dbe-4249-4b25-9a63-3a10cfaeb61ca",
+ "type": "default",
+ "source": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "target": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "sourceHandle": "value",
+ "targetHandle": "a"
+ },
+ {
+ "id": "reactflow__edge-49a8cc12-aa19-48c5-b6b3-04e0b603b384value-c8f5c671-8c87-4d96-a75e-a9937ac6bc03a",
+ "type": "default",
+ "source": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "target": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-14e65dbe-4249-4b25-9a63-3a10cfaeb61cvalue-49a8cc12-aa19-48c5-b6b3-04e0b603b384a",
+ "type": "default",
+ "source": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "target": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-6636a27a-f130-4a13-b3e5-50b44e4a566fcollection-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7acontrol",
+ "type": "default",
+ "source": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "collection",
+ "targetHandle": "control"
+ },
+ {
+ "id": "reactflow__edge-b78f53b6-2eae-4956-97b4-7e73768d1491control-6636a27a-f130-4a13-b3e5-50b44e4a566fitem",
+ "type": "default",
+ "source": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "target": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "sourceHandle": "control",
+ "targetHandle": "item"
+ },
+ {
+ "id": "reactflow__edge-be4082d6-e238-40ea-a9df-fc0d725e8895control-6636a27a-f130-4a13-b3e5-50b44e4a566fitem",
+ "type": "default",
+ "source": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "target": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "sourceHandle": "control",
+ "targetHandle": "item"
+ },
+ {
+ "id": "reactflow__edge-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7alatents-c3b60a50-8039-4924-90e3-8c608e1fecb5latents",
+ "type": "default",
+ "source": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-117f982a-03da-49b1-bf9f-29711160ac02latents-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7alatents",
+ "type": "default",
+ "source": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-8923451b-5a27-4395-b7f2-dce875fca6f5noise-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7anoise",
+ "type": "default",
+ "source": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "noise",
+ "targetHandle": "noise"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json
new file mode 100644
index 00000000000..8b57bf46b6c
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json
@@ -0,0 +1,1624 @@
+{
+ "id": "default_35658541-6d41-4a20-8ec5-4bf2561faed0",
+ "name": "Upscaler - SDXL, MultiDiffusion",
+ "author": "Invoke",
+ "description": "A workflow to upscale an input image with tiled upscaling, using SDXL based models.",
+ "version": "1.1.0",
+ "contact": "invoke@invoke.ai",
+ "tags": "sdxl, upscaling",
+ "notes": "",
+ "exposedFields": [
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "image"
+ },
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "scale"
+ },
+ {
+ "nodeId": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "fieldName": "board"
+ },
+ {
+ "nodeId": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "fieldName": "a"
+ },
+ {
+ "nodeId": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "fieldName": "a"
+ },
+ {
+ "nodeId": "c26bff37-4f12-482f-ba45-3a5d729b4c4f",
+ "fieldName": "value"
+ },
+ {
+ "nodeId": "f5ca24ee-21c5-4c8c-8d3c-371b5079b086",
+ "fieldName": "value"
+ },
+ {
+ "nodeId": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3a",
+ "fieldName": "vae_model"
+ },
+ {
+ "nodeId": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "fieldName": "image_to_image_model"
+ },
+ {
+ "nodeId": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "fieldName": "model"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "71a116e1-c631-48b3-923d-acea4753b887",
+ "type": "invocation",
+ "data": {
+ "id": "71a116e1-c631-48b3-923d-acea4753b887",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.3
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1550
+ }
+ },
+ {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "type": "invocation",
+ "data": {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.025
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1575
+ }
+ },
+ {
+ "id": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "type": "invocation",
+ "data": {
+ "id": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.45
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1200
+ }
+ },
+ {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "type": "invocation",
+ "data": {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.15
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1225
+ }
+ },
+ {
+ "id": "1ed88043-3519-41d5-a895-07944f03de70",
+ "type": "invocation",
+ "data": {
+ "id": "1ed88043-3519-41d5-a895-07944f03de70",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.3
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1650
+ }
+ },
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "type": "invocation",
+ "data": {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 0.032
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -1850
+ }
+ },
+ {
+ "id": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "type": "invocation",
+ "data": {
+ "id": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "type": "spandrel_image_to_image_autoscale",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": "Image to Upscale"
+ },
+ "image_to_image_model": {
+ "name": "image_to_image_model",
+ "label": "",
+ "value": {
+ "key": "38bb1a29-8ede-42ba-b77f-64b3478896eb",
+ "hash": "blake3:e52fdbee46a484ebe9b3b20ea0aac0a35a453ab6d0d353da00acfd35ce7a91ed",
+ "name": "4xNomosWebPhoto_esrgan",
+ "base": "sdxl",
+ "type": "spandrel_image_to_image"
+ }
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 512
+ },
+ "scale": {
+ "name": "scale",
+ "label": "Scale (2x, 4x, 8x, 16x)",
+ "value": 2
+ },
+ "fit_to_multiple_of_8": {
+ "name": "fit_to_multiple_of_8",
+ "label": "",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -4750,
+ "y": -2125
+ }
+ },
+ {
+ "id": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "type": "invocation",
+ "data": {
+ "id": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "type": "model_identifier",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": "ControlNet Model - Choose a Tile ControlNet"
+ }
+ }
+ },
+ "position": {
+ "x": -3450,
+ "y": -1450
+ }
+ },
+ {
+ "id": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "type": "invocation",
+ "data": {
+ "id": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": false,
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2075
+ }
+ },
+ {
+ "id": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "type": "invocation",
+ "data": {
+ "id": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "type": "boolean",
+ "version": "1.0.1",
+ "label": "Tiled Option",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "Tiled VAE (Saves VRAM, Color Inconsistency)",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -2475
+ }
+ },
+ {
+ "id": "f5ca24ee-21c5-4c8c-8d3c-371b5079b086",
+ "type": "invocation",
+ "data": {
+ "id": "f5ca24ee-21c5-4c8c-8d3c-371b5079b086",
+ "type": "string",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "Negative Prompt (Optional)",
+ "value": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2525
+ }
+ },
+ {
+ "id": "c26bff37-4f12-482f-ba45-3a5d729b4c4f",
+ "type": "invocation",
+ "data": {
+ "id": "c26bff37-4f12-482f-ba45-3a5d729b4c4f",
+ "type": "string",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "Positive Prompt (Optional)",
+ "value": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2825
+ }
+ },
+ {
+ "id": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "type": "invocation",
+ "data": {
+ "id": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "Creativity Input",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "Creativity Control (-10 to 10)",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": -1
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2125
+ }
+ },
+ {
+ "id": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "type": "invocation",
+ "data": {
+ "id": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "DIV"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 100
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -1975
+ }
+ },
+ {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "type": "invocation",
+ "data": {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "A",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 10
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2075
+ }
+ },
+ {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "type": "invocation",
+ "data": {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "MUL"
+ },
+ "a": {
+ "name": "a",
+ "label": "",
+ "value": 1
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 4.99
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2025
+ }
+ },
+ {
+ "id": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "type": "invocation",
+ "data": {
+ "id": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "type": "float_math",
+ "version": "1.0.1",
+ "label": "Structural Input",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "ADD"
+ },
+ "a": {
+ "name": "a",
+ "label": "Structural Control (-10 to 10)",
+ "value": 0
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 10
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -2100
+ }
+ },
+ {
+ "id": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "type": "invocation",
+ "data": {
+ "id": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "type": "collect",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "item": {
+ "name": "item",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -2275,
+ "y": -2075
+ }
+ },
+ {
+ "id": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "type": "invocation",
+ "data": {
+ "id": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "type": "controlnet",
+ "version": "1.1.2",
+ "label": "Initial Control (Use Tile)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": ""
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 0.6
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.5
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -1775
+ }
+ },
+ {
+ "id": "27215391-b20e-412a-b854-7fa5927f5437",
+ "type": "invocation",
+ "data": {
+ "id": "27215391-b20e-412a-b854-7fa5927f5437",
+ "type": "sdxl_compel_prompt",
+ "version": "1.2.0",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ },
+ "style": {
+ "name": "style",
+ "label": "",
+ "value": ""
+ },
+ "original_width": {
+ "name": "original_width",
+ "label": "",
+ "value": 4096
+ },
+ "original_height": {
+ "name": "original_height",
+ "label": "",
+ "value": 4096
+ },
+ "crop_top": {
+ "name": "crop_top",
+ "label": "",
+ "value": 0
+ },
+ "crop_left": {
+ "name": "crop_left",
+ "label": "",
+ "value": 0
+ },
+ "target_width": {
+ "name": "target_width",
+ "label": "",
+ "value": 1024
+ },
+ "target_height": {
+ "name": "target_height",
+ "label": "",
+ "value": 1024
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "clip2": {
+ "name": "clip2",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2300
+ }
+ },
+ {
+ "id": "100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3a",
+ "type": "invocation",
+ "data": {
+ "id": "100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3a",
+ "type": "vae_loader",
+ "version": "1.0.3",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "vae_model": {
+ "name": "vae_model",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2575
+ }
+ },
+ {
+ "id": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "type": "invocation",
+ "data": {
+ "id": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "type": "sdxl_model_loader",
+ "version": "1.0.3",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": "SDXL Model"
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2825
+ }
+ },
+ {
+ "id": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "type": "invocation",
+ "data": {
+ "id": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "type": "sdxl_compel_prompt",
+ "version": "1.2.0",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ },
+ "style": {
+ "name": "style",
+ "label": "",
+ "value": ""
+ },
+ "original_width": {
+ "name": "original_width",
+ "label": "",
+ "value": 4096
+ },
+ "original_height": {
+ "name": "original_height",
+ "label": "",
+ "value": 4096
+ },
+ "crop_top": {
+ "name": "crop_top",
+ "label": "",
+ "value": 0
+ },
+ "crop_left": {
+ "name": "crop_left",
+ "label": "",
+ "value": 0
+ },
+ "target_width": {
+ "name": "target_width",
+ "label": "",
+ "value": 1024
+ },
+ "target_height": {
+ "name": "target_height",
+ "label": "",
+ "value": 1024
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "clip2": {
+ "name": "clip2",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3500,
+ "y": -2600
+ }
+ },
+ {
+ "id": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "type": "invocation",
+ "data": {
+ "id": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "type": "unsharp_mask",
+ "version": "1.2.2",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "radius": {
+ "name": "radius",
+ "label": "",
+ "value": 2
+ },
+ "strength": {
+ "name": "strength",
+ "label": "",
+ "value": 50
+ }
+ }
+ },
+ "position": {
+ "x": -4400,
+ "y": -2125
+ }
+ },
+ {
+ "id": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "type": "invocation",
+ "data": {
+ "id": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "type": "i2l",
+ "version": "1.1.0",
+ "label": "",
+ "notes": "",
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2125
+ }
+ },
+ {
+ "id": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "type": "invocation",
+ "data": {
+ "id": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "type": "l2i",
+ "version": "1.3.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": "Output Board"
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -2825
+ }
+ },
+ {
+ "id": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "type": "invocation",
+ "data": {
+ "id": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "type": "tiled_multi_diffusion_denoise_latents",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "positive_conditioning": {
+ "name": "positive_conditioning",
+ "label": ""
+ },
+ "negative_conditioning": {
+ "name": "negative_conditioning",
+ "label": ""
+ },
+ "noise": {
+ "name": "noise",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "tile_height": {
+ "name": "tile_height",
+ "label": "",
+ "value": 1024
+ },
+ "tile_width": {
+ "name": "tile_width",
+ "label": "",
+ "value": 1024
+ },
+ "tile_overlap": {
+ "name": "tile_overlap",
+ "label": "",
+ "value": 128
+ },
+ "steps": {
+ "name": "steps",
+ "label": "",
+ "value": 25
+ },
+ "cfg_scale": {
+ "name": "cfg_scale",
+ "label": "",
+ "value": 5
+ },
+ "denoising_start": {
+ "name": "denoising_start",
+ "label": "",
+ "value": 0.6
+ },
+ "denoising_end": {
+ "name": "denoising_end",
+ "label": "",
+ "value": 1
+ },
+ "scheduler": {
+ "name": "scheduler",
+ "label": "",
+ "value": "kdpm_2"
+ },
+ "unet": {
+ "name": "unet",
+ "label": ""
+ },
+ "cfg_rescale_multiplier": {
+ "name": "cfg_rescale_multiplier",
+ "label": "",
+ "value": 0
+ },
+ "control": {
+ "name": "control",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -3050,
+ "y": -2825
+ }
+ },
+ {
+ "id": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "type": "invocation",
+ "data": {
+ "id": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "type": "controlnet",
+ "version": "1.1.2",
+ "label": "Second Phase Control (Use Tile)",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": ""
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 0.25
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0.5
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 0.85
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "Control Mode",
+ "value": "balanced"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
+ }
+ }
+ },
+ "position": {
+ "x": -2675,
+ "y": -1325
+ }
+ },
+ {
+ "id": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "type": "invocation",
+ "data": {
+ "id": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "type": "noise",
+ "version": "1.0.2",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "inputs": {
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 3
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 512
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 512
+ },
+ "use_cpu": {
+ "name": "use_cpu",
+ "label": "",
+ "value": true
+ }
+ }
+ },
+ "position": {
+ "x": -4025,
+ "y": -2025
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21-75a89685-0f82-40ed-9b88-e583673be9fc-collapsed",
+ "type": "collapsed",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "75a89685-0f82-40ed-9b88-e583673be9fc"
+ },
+ {
+ "id": "9b281506-4079-4a3d-ab40-b386156fcd21-1ed88043-3519-41d5-a895-07944f03de70-collapsed",
+ "type": "collapsed",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "1ed88043-3519-41d5-a895-07944f03de70"
+ },
+ {
+ "id": "49a8cc12-aa19-48c5-b6b3-04e0b603b384-c8f5c671-8c87-4d96-a75e-a9937ac6bc03-collapsed",
+ "type": "collapsed",
+ "source": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "target": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03"
+ },
+ {
+ "id": "reactflow__edge-c8f5c671-8c87-4d96-a75e-a9937ac6bc03value-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7adenoising_start",
+ "type": "default",
+ "source": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "value",
+ "targetHandle": "denoising_start"
+ },
+ {
+ "id": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c-49a8cc12-aa19-48c5-b6b3-04e0b603b384-collapsed",
+ "type": "collapsed",
+ "source": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "target": "49a8cc12-aa19-48c5-b6b3-04e0b603b384"
+ },
+ {
+ "id": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d-14e65dbe-4249-4b25-9a63-3a10cfaeb61c-collapsed",
+ "type": "collapsed",
+ "source": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "target": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c"
+ },
+ {
+ "id": "75a89685-0f82-40ed-9b88-e583673be9fc-96e1bcd0-326b-4b67-8b14-239da2440aec-collapsed",
+ "type": "collapsed",
+ "source": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "target": "96e1bcd0-326b-4b67-8b14-239da2440aec"
+ },
+ {
+ "id": "00e2c587-f047-4413-ad15-bd31ea53ce22-71a116e1-c631-48b3-923d-acea4753b887-collapsed",
+ "type": "collapsed",
+ "source": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "target": "71a116e1-c631-48b3-923d-acea4753b887"
+ },
+ {
+ "id": "reactflow__edge-71a116e1-c631-48b3-923d-acea4753b887value-be4082d6-e238-40ea-a9df-fc0d725e8895begin_step_percent",
+ "type": "default",
+ "source": "71a116e1-c631-48b3-923d-acea4753b887",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "value",
+ "targetHandle": "begin_step_percent"
+ },
+ {
+ "id": "reactflow__edge-71a116e1-c631-48b3-923d-acea4753b887value-b78f53b6-2eae-4956-97b4-7e73768d1491end_step_percent",
+ "type": "default",
+ "source": "71a116e1-c631-48b3-923d-acea4753b887",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "value",
+ "targetHandle": "end_step_percent"
+ },
+ {
+ "id": "reactflow__edge-00e2c587-f047-4413-ad15-bd31ea53ce22value-71a116e1-c631-48b3-923d-acea4753b887a",
+ "type": "default",
+ "source": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "target": "71a116e1-c631-48b3-923d-acea4753b887",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-bd094e2f-41e5-4b61-9f7b-56cf337d53favalue-00e2c587-f047-4413-ad15-bd31ea53ce22a",
+ "type": "default",
+ "source": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "target": "00e2c587-f047-4413-ad15-bd31ea53ce22",
+ "sourceHandle": "value",
+ "targetHandle": "a"
+ },
+ {
+ "id": "reactflow__edge-96e1bcd0-326b-4b67-8b14-239da2440aecvalue-be4082d6-e238-40ea-a9df-fc0d725e8895control_weight",
+ "type": "default",
+ "source": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "value",
+ "targetHandle": "control_weight"
+ },
+ {
+ "id": "reactflow__edge-75a89685-0f82-40ed-9b88-e583673be9fcvalue-96e1bcd0-326b-4b67-8b14-239da2440aeca",
+ "type": "default",
+ "source": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "target": "96e1bcd0-326b-4b67-8b14-239da2440aec",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-9b281506-4079-4a3d-ab40-b386156fcd21value-75a89685-0f82-40ed-9b88-e583673be9fca",
+ "type": "default",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "75a89685-0f82-40ed-9b88-e583673be9fc",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-1ed88043-3519-41d5-a895-07944f03de70value-b78f53b6-2eae-4956-97b4-7e73768d1491control_weight",
+ "type": "default",
+ "source": "1ed88043-3519-41d5-a895-07944f03de70",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "value",
+ "targetHandle": "control_weight"
+ },
+ {
+ "id": "reactflow__edge-9b281506-4079-4a3d-ab40-b386156fcd21value-1ed88043-3519-41d5-a895-07944f03de70a",
+ "type": "default",
+ "source": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "target": "1ed88043-3519-41d5-a895-07944f03de70",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-bd094e2f-41e5-4b61-9f7b-56cf337d53favalue-9b281506-4079-4a3d-ab40-b386156fcd21a",
+ "type": "default",
+ "source": "bd094e2f-41e5-4b61-9f7b-56cf337d53fa",
+ "target": "9b281506-4079-4a3d-ab40-b386156fcd21",
+ "sourceHandle": "value",
+ "targetHandle": "a"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeheight-8923451b-5a27-4395-b7f2-dce875fca6f5height",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "height",
+ "targetHandle": "height"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebewidth-8923451b-5a27-4395-b7f2-dce875fca6f5width",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "width",
+ "targetHandle": "width"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-b78f53b6-2eae-4956-97b4-7e73768d1491image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-be4082d6-e238-40ea-a9df-fc0d725e8895image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage-117f982a-03da-49b1-bf9f-29711160ac02image",
+ "type": "default",
+ "source": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-011039f6-04cf-4607-8eb1-3304eb819c8cimage-041c59cc-f9e4-4dc9-8b31-84648c5f3ebeimage",
+ "type": "default",
+ "source": "011039f6-04cf-4607-8eb1-3304eb819c8c",
+ "target": "041c59cc-f9e4-4dc9-8b31-84648c5f3ebe",
+ "sourceHandle": "image",
+ "targetHandle": "image"
+ },
+ {
+ "id": "reactflow__edge-f936ebb3-6902-4df9-a775-6a68bac2da70model-be4082d6-e238-40ea-a9df-fc0d725e8895control_model",
+ "type": "default",
+ "source": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "target": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "sourceHandle": "model",
+ "targetHandle": "control_model"
+ },
+ {
+ "id": "reactflow__edge-f936ebb3-6902-4df9-a775-6a68bac2da70model-b78f53b6-2eae-4956-97b4-7e73768d1491control_model",
+ "type": "default",
+ "source": "f936ebb3-6902-4df9-a775-6a68bac2da70",
+ "target": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "sourceHandle": "model",
+ "targetHandle": "control_model"
+ },
+ {
+ "id": "reactflow__edge-00239057-20d4-4cd2-a010-28727b256ea2value-8923451b-5a27-4395-b7f2-dce875fca6f5seed",
+ "type": "default",
+ "source": "00239057-20d4-4cd2-a010-28727b256ea2",
+ "target": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "reactflow__edge-094bc4ed-5c68-4342-84f4-51056c755796value-c3b60a50-8039-4924-90e3-8c608e1fecb5tiled",
+ "type": "default",
+ "source": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "value",
+ "targetHandle": "tiled"
+ },
+ {
+ "id": "reactflow__edge-094bc4ed-5c68-4342-84f4-51056c755796value-117f982a-03da-49b1-bf9f-29711160ac02tiled",
+ "type": "default",
+ "source": "094bc4ed-5c68-4342-84f4-51056c755796",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "value",
+ "targetHandle": "tiled"
+ },
+ {
+ "id": "reactflow__edge-f5ca24ee-21c5-4c8c-8d3c-371b5079b086value-27215391-b20e-412a-b854-7fa5927f5437style",
+ "type": "default",
+ "source": "f5ca24ee-21c5-4c8c-8d3c-371b5079b086",
+ "target": "27215391-b20e-412a-b854-7fa5927f5437",
+ "sourceHandle": "value",
+ "targetHandle": "style"
+ },
+ {
+ "id": "reactflow__edge-f5ca24ee-21c5-4c8c-8d3c-371b5079b086value-27215391-b20e-412a-b854-7fa5927f5437prompt",
+ "type": "default",
+ "source": "f5ca24ee-21c5-4c8c-8d3c-371b5079b086",
+ "target": "27215391-b20e-412a-b854-7fa5927f5437",
+ "sourceHandle": "value",
+ "targetHandle": "prompt"
+ },
+ {
+ "id": "reactflow__edge-c26bff37-4f12-482f-ba45-3a5d729b4c4fvalue-6142b69a-323f-4ecd-a7e5-67dc61349c51style",
+ "type": "default",
+ "source": "c26bff37-4f12-482f-ba45-3a5d729b4c4f",
+ "target": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "sourceHandle": "value",
+ "targetHandle": "style"
+ },
+ {
+ "id": "reactflow__edge-c26bff37-4f12-482f-ba45-3a5d729b4c4fvalue-6142b69a-323f-4ecd-a7e5-67dc61349c51prompt",
+ "type": "default",
+ "source": "c26bff37-4f12-482f-ba45-3a5d729b4c4f",
+ "target": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "sourceHandle": "value",
+ "targetHandle": "prompt"
+ },
+ {
+ "id": "reactflow__edge-1dd915a3-6756-48ed-b68b-ee3b4bd06c1dvalue-14e65dbe-4249-4b25-9a63-3a10cfaeb61ca",
+ "type": "default",
+ "source": "1dd915a3-6756-48ed-b68b-ee3b4bd06c1d",
+ "target": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-49a8cc12-aa19-48c5-b6b3-04e0b603b384value-c8f5c671-8c87-4d96-a75e-a9937ac6bc03a",
+ "type": "default",
+ "source": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "target": "c8f5c671-8c87-4d96-a75e-a9937ac6bc03",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-14e65dbe-4249-4b25-9a63-3a10cfaeb61cvalue-49a8cc12-aa19-48c5-b6b3-04e0b603b384a",
+ "type": "default",
+ "source": "14e65dbe-4249-4b25-9a63-3a10cfaeb61c",
+ "target": "49a8cc12-aa19-48c5-b6b3-04e0b603b384",
+ "sourceHandle": "value",
+ "targetHandle": "a",
+ "hidden": true
+ },
+ {
+ "id": "reactflow__edge-6636a27a-f130-4a13-b3e5-50b44e4a566fcollection-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7acontrol",
+ "type": "default",
+ "source": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "collection",
+ "targetHandle": "control"
+ },
+ {
+ "id": "reactflow__edge-b78f53b6-2eae-4956-97b4-7e73768d1491control-6636a27a-f130-4a13-b3e5-50b44e4a566fitem",
+ "type": "default",
+ "source": "b78f53b6-2eae-4956-97b4-7e73768d1491",
+ "target": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "sourceHandle": "control",
+ "targetHandle": "item"
+ },
+ {
+ "id": "reactflow__edge-be4082d6-e238-40ea-a9df-fc0d725e8895control-6636a27a-f130-4a13-b3e5-50b44e4a566fitem",
+ "type": "default",
+ "source": "be4082d6-e238-40ea-a9df-fc0d725e8895",
+ "target": "6636a27a-f130-4a13-b3e5-50b44e4a566f",
+ "sourceHandle": "control",
+ "targetHandle": "item"
+ },
+ {
+ "id": "reactflow__edge-e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fdclip2-27215391-b20e-412a-b854-7fa5927f5437clip2",
+ "type": "default",
+ "source": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "target": "27215391-b20e-412a-b854-7fa5927f5437",
+ "sourceHandle": "clip2",
+ "targetHandle": "clip2"
+ },
+ {
+ "id": "reactflow__edge-e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fdclip-27215391-b20e-412a-b854-7fa5927f5437clip",
+ "type": "default",
+ "source": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "target": "27215391-b20e-412a-b854-7fa5927f5437",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ },
+ {
+ "id": "reactflow__edge-e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fdclip2-6142b69a-323f-4ecd-a7e5-67dc61349c51clip2",
+ "type": "default",
+ "source": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "target": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "sourceHandle": "clip2",
+ "targetHandle": "clip2"
+ },
+ {
+ "id": "reactflow__edge-6142b69a-323f-4ecd-a7e5-67dc61349c51conditioning-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7apositive_conditioning",
+ "type": "default",
+ "source": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "reactflow__edge-27215391-b20e-412a-b854-7fa5927f5437conditioning-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7anegative_conditioning",
+ "type": "default",
+ "source": "27215391-b20e-412a-b854-7fa5927f5437",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ },
+ {
+ "id": "reactflow__edge-e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fdunet-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7aunet",
+ "type": "default",
+ "source": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "unet",
+ "targetHandle": "unet"
+ },
+ {
+ "id": "reactflow__edge-100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3avae-117f982a-03da-49b1-bf9f-29711160ac02vae",
+ "type": "default",
+ "source": "100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3a",
+ "target": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3avae-c3b60a50-8039-4924-90e3-8c608e1fecb5vae",
+ "type": "default",
+ "source": "100b3143-b3fb-4ff3-bb3c-8d4d3f89ae3a",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fdclip-6142b69a-323f-4ecd-a7e5-67dc61349c51clip",
+ "type": "default",
+ "source": "e277e4b7-01cd-4daa-86ab-7bfa3cdcd9fd",
+ "target": "6142b69a-323f-4ecd-a7e5-67dc61349c51",
+ "sourceHandle": "clip",
+ "targetHandle": "clip"
+ },
+ {
+ "id": "reactflow__edge-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7alatents-c3b60a50-8039-4924-90e3-8c608e1fecb5latents",
+ "type": "default",
+ "source": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "target": "c3b60a50-8039-4924-90e3-8c608e1fecb5",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-117f982a-03da-49b1-bf9f-29711160ac02latents-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7alatents",
+ "type": "default",
+ "source": "117f982a-03da-49b1-bf9f-29711160ac02",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-8923451b-5a27-4395-b7f2-dce875fca6f5noise-8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7anoise",
+ "type": "default",
+ "source": "8923451b-5a27-4395-b7f2-dce875fca6f5",
+ "target": "8dba0d37-cd2e-4fe5-ae9f-5464b85a8a7a",
+ "sourceHandle": "noise",
+ "targetHandle": "noise"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json
index 765b236714f..747213e140b 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json
+++ b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json
@@ -1,10 +1,11 @@
{
- "name": "Prompt from File",
+ "id": "default_d7a1c60f-ca2f-4f90-9e33-75a826ca6d8f",
+ "name": "Text to Image - SD1.5, Prompt from File",
"author": "InvokeAI",
"description": "Sample workflow using Prompt from File node",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "text2image, prompt from file, default",
+ "tags": "sd1.5, text to image",
"notes": "",
"exposedFields": [
{
@@ -37,16 +38,127 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
+ {
+ "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
+ "type": "invocation",
+ "data": {
+ "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
+ "version": "1.3.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "l2i",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 2037.861329274915,
+ "y": -329.8393457509562
+ }
+ },
+ {
+ "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
+ "type": "invocation",
+ "data": {
+ "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
+ "version": "1.2.0",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "compel",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 925,
+ "y": -275
+ }
+ },
+ {
+ "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
+ "type": "invocation",
+ "data": {
+ "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
+ "version": "1.0.3",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "main_model_loader",
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 0,
+ "y": -375
+ }
+ },
{
"id": "c2eaf1ba-5708-4679-9e15-945b8b432692",
"type": "invocation",
"data": {
"id": "c2eaf1ba-5708-4679-9e15-945b8b432692",
- "version": "1.1.1",
+ "version": "1.2.0",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -60,6 +172,10 @@
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": false,
@@ -141,61 +257,6 @@
"y": -400
}
},
- {
- "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "type": "invocation",
- "data": {
- "id": "d6353b7f-b447-4e17-8f2e-80a88c91d426",
- "version": "1.0.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "name": "model",
- "label": ""
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 0,
- "y": -375
- }
- },
- {
- "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "type": "invocation",
- "data": {
- "id": "fc9d0e35-a6de-4a19-84e1-c72497c823f6",
- "version": "1.1.1",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "compel",
- "inputs": {
- "prompt": {
- "name": "prompt",
- "label": "",
- "value": ""
- },
- "clip": {
- "name": "clip",
- "label": ""
- }
- },
- "isOpen": false,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 925,
- "y": -275
- }
- },
{
"id": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77",
"type": "invocation",
@@ -268,53 +329,6 @@
"y": -50
}
},
- {
- "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "type": "invocation",
- "data": {
- "id": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 2037.861329274915,
- "y": -329.8393457509562
- }
- },
{
"id": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e",
"type": "invocation",
@@ -499,4 +513,4 @@
"targetHandle": "vae"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/README.md b/invokeai/app/services/workflow_records/default_workflows/README.md
index 3901ead1cd5..a70cc14c879 100644
--- a/invokeai/app/services/workflow_records/default_workflows/README.md
+++ b/invokeai/app/services/workflow_records/default_workflows/README.md
@@ -3,12 +3,14 @@
Workflows placed in this directory will be synced to the `workflow_library` as
_default workflows_ on app startup.
+- Default workflows must have an id that starts with "default\_". The ID must be retained when the workflow is updated. You may need to do this manually.
- Default workflows are not editable by users. If they are loaded and saved,
they will save as a copy of the default workflow.
- Default workflows must have the `meta.category` property set to `"default"`.
An exception will be raised during sync if this is not set correctly.
- Default workflows appear on the "Default Workflows" tab of the Workflow
Library.
+- Default workflows should not reference any resources that are user-created or installed. That includes images and models. For example, if a default workflow references Juggernaut as an SDXL model, when a user loads the workflow, even if they have a version of Juggernaut installed, it will have a different UUID. They may see a warning. So, it's best to ship default workflows without any references to these types of resources.
After adding or updating default workflows, you **must** start the app up and
load them to ensure:
diff --git a/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json
new file mode 100644
index 00000000000..64d2a3ef779
--- /dev/null
+++ b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json
@@ -0,0 +1,375 @@
+{
+ "id": "default_dbe46d95-22aa-43fb-9c16-94400d0ce2fd",
+ "name": "Text to Image - SD3.5",
+ "author": "InvokeAI",
+ "description": "Sample text to image workflow for Stable Diffusion 3.5",
+ "version": "1.0.0",
+ "contact": "invoke@invoke.ai",
+ "tags": "SD3.5, text to image",
+ "notes": "",
+ "exposedFields": [
+ {
+ "nodeId": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "fieldName": "model"
+ },
+ {
+ "nodeId": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "fieldName": "prompt"
+ }
+ ],
+ "meta": {
+ "version": "3.0.0",
+ "category": "default"
+ },
+ "nodes": [
+ {
+ "id": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "type": "invocation",
+ "data": {
+ "id": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "type": "sd3_model_loader",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ },
+ "t5_encoder_model": {
+ "name": "t5_encoder_model",
+ "label": ""
+ },
+ "clip_l_model": {
+ "name": "clip_l_model",
+ "label": ""
+ },
+ "clip_g_model": {
+ "name": "clip_g_model",
+ "label": ""
+ },
+ "vae_model": {
+ "name": "vae_model",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": -55.58689609637031,
+ "y": -111.53602444662268
+ }
+ },
+ {
+ "id": "f7e394ac-6394-4096-abcb-de0d346506b3",
+ "type": "invocation",
+ "data": {
+ "id": "f7e394ac-6394-4096-abcb-de0d346506b3",
+ "type": "rand_int",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": false,
+ "nodePack": "invokeai",
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ }
+ },
+ "position": {
+ "x": 470.45870147220353,
+ "y": 350.3141781644303
+ }
+ },
+ {
+ "id": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b",
+ "type": "invocation",
+ "data": {
+ "id": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b",
+ "type": "sd3_l2i",
+ "version": "1.3.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": false,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ }
+ }
+ },
+ "position": {
+ "x": 1192.3097009334897,
+ "y": -366.0994675072209
+ }
+ },
+ {
+ "id": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "type": "invocation",
+ "data": {
+ "id": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "type": "sd3_text_encoder",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "clip_l": {
+ "name": "clip_l",
+ "label": ""
+ },
+ "clip_g": {
+ "name": "clip_g",
+ "label": ""
+ },
+ "t5_encoder": {
+ "name": "t5_encoder",
+ "label": ""
+ },
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ }
+ }
+ },
+ "position": {
+ "x": 408.16054647924784,
+ "y": 65.06415352118786
+ }
+ },
+ {
+ "id": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "type": "invocation",
+ "data": {
+ "id": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "type": "sd3_text_encoder",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "clip_l": {
+ "name": "clip_l",
+ "label": ""
+ },
+ "clip_g": {
+ "name": "clip_g",
+ "label": ""
+ },
+ "t5_encoder": {
+ "name": "t5_encoder",
+ "label": ""
+ },
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ }
+ }
+ },
+ "position": {
+ "x": 378.9283412440941,
+ "y": -302.65777497352553
+ }
+ },
+ {
+ "id": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "type": "invocation",
+ "data": {
+ "id": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "type": "sd3_denoise",
+ "version": "1.0.0",
+ "label": "",
+ "notes": "",
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true,
+ "nodePack": "invokeai",
+ "inputs": {
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "transformer": {
+ "name": "transformer",
+ "label": ""
+ },
+ "positive_conditioning": {
+ "name": "positive_conditioning",
+ "label": ""
+ },
+ "negative_conditioning": {
+ "name": "negative_conditioning",
+ "label": ""
+ },
+ "cfg_scale": {
+ "name": "cfg_scale",
+ "label": "",
+ "value": 3.5
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 1024
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 1024
+ },
+ "steps": {
+ "name": "steps",
+ "label": "",
+ "value": 30
+ },
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 0
+ }
+ }
+ },
+ "position": {
+ "x": 813.7814762740603,
+ "y": -142.20529727605867
+ }
+ }
+ ],
+ "edges": [
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cvae-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48bvae",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b",
+ "sourceHandle": "vae",
+ "targetHandle": "vae"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-3b4f7f27-cfc0-4373-a009-99c5290d0cd6t5_encoder",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "sourceHandle": "t5_encoder",
+ "targetHandle": "t5_encoder"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ct5_encoder-e17d34e7-6ed1-493c-9a85-4fcd291cb084t5_encoder",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "sourceHandle": "t5_encoder",
+ "targetHandle": "t5_encoder"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_g",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "sourceHandle": "clip_g",
+ "targetHandle": "clip_g"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_g-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_g",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "sourceHandle": "clip_g",
+ "targetHandle": "clip_g"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-3b4f7f27-cfc0-4373-a009-99c5290d0cd6clip_l",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "sourceHandle": "clip_l",
+ "targetHandle": "clip_l"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4cclip_l-e17d34e7-6ed1-493c-9a85-4fcd291cb084clip_l",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "sourceHandle": "clip_l",
+ "targetHandle": "clip_l"
+ },
+ {
+ "id": "reactflow__edge-3f22f668-0e02-4fde-a2bb-c339586ceb4ctransformer-c7539f7b-7ac5-49b9-93eb-87ede611409ftransformer",
+ "type": "default",
+ "source": "3f22f668-0e02-4fde-a2bb-c339586ceb4c",
+ "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "sourceHandle": "transformer",
+ "targetHandle": "transformer"
+ },
+ {
+ "id": "reactflow__edge-f7e394ac-6394-4096-abcb-de0d346506b3value-c7539f7b-7ac5-49b9-93eb-87ede611409fseed",
+ "type": "default",
+ "source": "f7e394ac-6394-4096-abcb-de0d346506b3",
+ "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "sourceHandle": "value",
+ "targetHandle": "seed"
+ },
+ {
+ "id": "reactflow__edge-c7539f7b-7ac5-49b9-93eb-87ede611409flatents-9eb72af0-dd9e-4ec5-ad87-d65e3c01f48blatents",
+ "type": "default",
+ "source": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "target": "9eb72af0-dd9e-4ec5-ad87-d65e3c01f48b",
+ "sourceHandle": "latents",
+ "targetHandle": "latents"
+ },
+ {
+ "id": "reactflow__edge-e17d34e7-6ed1-493c-9a85-4fcd291cb084conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fpositive_conditioning",
+ "type": "default",
+ "source": "e17d34e7-6ed1-493c-9a85-4fcd291cb084",
+ "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "sourceHandle": "conditioning",
+ "targetHandle": "positive_conditioning"
+ },
+ {
+ "id": "reactflow__edge-3b4f7f27-cfc0-4373-a009-99c5290d0cd6conditioning-c7539f7b-7ac5-49b9-93eb-87ede611409fnegative_conditioning",
+ "type": "default",
+ "source": "3b4f7f27-cfc0-4373-a009-99c5290d0cd6",
+ "target": "c7539f7b-7ac5-49b9-93eb-87ede611409f",
+ "sourceHandle": "conditioning",
+ "targetHandle": "negative_conditioning"
+ }
+ ]
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json
index d3d52150bc4..a6b4ddf6dfb 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json
+++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json
@@ -1,10 +1,11 @@
{
+ "id": "default_7dde3e36-d78f-4152-9eea-00ef9c8124ed",
"name": "Text to Image - SD1.5",
"author": "InvokeAI",
"description": "Sample text to image workflow for Stable Diffusion 1.5/2",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "text2image, SD1.5, SD2, default",
+ "tags": "SD1.5, text to image",
"notes": "",
"exposedFields": [
{
@@ -33,70 +34,85 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
{
- "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
+ "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
"type": "invocation",
"data": {
- "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
- "version": "1.1.1",
+ "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
+ "version": "1.3.0",
"nodePack": "invokeai",
- "label": "Negative Compel Prompt",
+ "label": "",
"notes": "",
- "type": "compel",
+ "type": "l2i",
"inputs": {
- "prompt": {
- "name": "prompt",
- "label": "Negative Prompt",
- "value": ""
+ "board": {
+ "name": "board",
+ "label": ""
},
- "clip": {
- "name": "clip",
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
"label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": true
}
},
"isOpen": true,
- "isIntermediate": true,
+ "isIntermediate": false,
"useCache": true
},
"position": {
- "x": 1000,
- "y": 350
+ "x": 1800,
+ "y": 25
}
},
{
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
+ "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
"type": "invocation",
"data": {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "version": "1.0.2",
+ "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
+ "version": "1.2.0",
"nodePack": "invokeai",
- "label": "",
+ "label": "Positive Compel Prompt",
"notes": "",
- "type": "noise",
+ "type": "compel",
"inputs": {
- "seed": {
- "name": "seed",
- "label": "",
- "value": 0
- },
- "width": {
- "name": "width",
- "label": "",
- "value": 512
+ "prompt": {
+ "name": "prompt",
+ "label": "Positive Prompt",
+ "value": "Super cute tiger cub, national geographic award-winning photograph"
},
- "height": {
- "name": "height",
- "label": "",
- "value": 768
+ "clip": {
+ "name": "clip",
+ "label": ""
},
- "use_cpu": {
- "name": "use_cpu",
- "label": "",
- "value": true
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -104,8 +120,8 @@
"useCache": true
},
"position": {
- "x": 600,
- "y": 325
+ "x": 1000,
+ "y": 25
}
},
{
@@ -113,7 +129,7 @@
"type": "invocation",
"data": {
"id": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8",
- "version": "1.0.2",
+ "version": "1.0.3",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -134,24 +150,28 @@
}
},
{
- "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
+ "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
"type": "invocation",
"data": {
- "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c",
- "version": "1.1.1",
+ "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402",
+ "version": "1.2.0",
"nodePack": "invokeai",
- "label": "Positive Compel Prompt",
+ "label": "Negative Compel Prompt",
"notes": "",
"type": "compel",
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Positive Prompt",
- "value": "Super cute tiger cub, national geographic award-winning photograph"
+ "label": "Negative Prompt",
+ "value": ""
},
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -160,7 +180,48 @@
},
"position": {
"x": 1000,
- "y": 25
+ "y": 350
+ }
+ },
+ {
+ "id": "55705012-79b9-4aac-9f26-c0b10309785b",
+ "type": "invocation",
+ "data": {
+ "id": "55705012-79b9-4aac-9f26-c0b10309785b",
+ "version": "1.0.2",
+ "nodePack": "invokeai",
+ "label": "",
+ "notes": "",
+ "type": "noise",
+ "inputs": {
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 0
+ },
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 512
+ },
+ "height": {
+ "name": "height",
+ "label": "",
+ "value": 768
+ },
+ "use_cpu": {
+ "name": "use_cpu",
+ "label": "",
+ "value": true
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 600,
+ "y": 325
}
},
{
@@ -280,53 +341,6 @@
"x": 1400,
"y": 25
}
- },
- {
- "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "type": "invocation",
- "data": {
- "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f",
- "version": "1.2.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": true
- }
- },
- "isOpen": true,
- "isIntermediate": false,
- "useCache": true
- },
- "position": {
- "x": 1800,
- "y": 25
- }
}
],
"edges": [
@@ -403,4 +417,4 @@
"targetHandle": "vae"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json
index 1527bbceb17..391ff46e9e5 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json
+++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json
@@ -1,10 +1,11 @@
{
+ "id": "default_5e8b008d-c697-45d0-8883-085a954c6ace",
"name": "Text to Image - SDXL",
"author": "InvokeAI",
"description": "Sample text to image workflow for SDXL",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "text2image, SDXL, default",
+ "tags": "SDXL, text to image",
"notes": "",
"exposedFields": [
{
@@ -29,84 +30,105 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
{
- "id": "3774ec24-a69e-4254-864c-097d07a6256f",
+ "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
"type": "invocation",
"data": {
- "id": "3774ec24-a69e-4254-864c-097d07a6256f",
- "version": "1.0.1",
- "label": "Positive Style Concat",
+ "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
+ "version": "1.0.3",
+ "label": "",
"notes": "",
- "type": "string_join",
+ "type": "vae_loader",
"inputs": {
- "string_left": {
- "name": "string_left",
- "label": "",
- "value": ""
- },
- "string_right": {
- "name": "string_right",
- "label": "Positive Style Concat",
- "value": ""
+ "vae_model": {
+ "name": "vae_model",
+ "label": "VAE (use the FP16 model)"
}
},
- "isOpen": false,
+ "isOpen": true,
"isIntermediate": true,
"useCache": true
},
"position": {
- "x": 750,
+ "x": 375,
"y": -225
}
},
{
- "id": "719dabe8-8297-4749-aea1-37be301cd425",
+ "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
"type": "invocation",
"data": {
- "id": "719dabe8-8297-4749-aea1-37be301cd425",
- "version": "1.0.1",
- "label": "Negative Prompt",
+ "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
+ "version": "1.3.0",
+ "nodePack": "invokeai",
+ "label": "",
"notes": "",
- "type": "string",
+ "type": "l2i",
"inputs": {
- "value": {
- "name": "value",
- "label": "Negative Prompt",
- "value": "photograph"
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
}
},
"isOpen": true,
- "isIntermediate": true,
- "useCache": true
+ "isIntermediate": false,
+ "useCache": false
},
"position": {
- "x": 750,
- "y": -125
+ "x": 1475,
+ "y": -500
}
},
{
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
+ "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
"type": "invocation",
"data": {
- "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
- "version": "1.1.1",
+ "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
+ "version": "1.2.0",
"nodePack": "invokeai",
- "label": "SDXL Negative Compel Prompt",
+ "label": "SDXL Positive Compel Prompt",
"notes": "",
"type": "sdxl_compel_prompt",
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Negative Prompt",
+ "label": "Positive Prompt",
"value": ""
},
"style": {
"name": "style",
- "label": "Negative Style",
+ "label": "Positive Style",
"value": ""
},
"original_width": {
@@ -146,6 +168,10 @@
"clip2": {
"name": "clip2",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": false,
@@ -154,79 +180,7 @@
},
"position": {
"x": 750,
- "y": 200
- }
- },
- {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "type": "invocation",
- "data": {
- "id": "55705012-79b9-4aac-9f26-c0b10309785b",
- "version": "1.0.2",
- "nodePack": "invokeai",
- "label": "",
- "notes": "",
- "type": "noise",
- "inputs": {
- "seed": {
- "name": "seed",
- "label": "",
- "value": 0
- },
- "width": {
- "name": "width",
- "label": "",
- "value": 1024
- },
- "height": {
- "name": "height",
- "label": "",
- "value": 1024
- },
- "use_cpu": {
- "name": "use_cpu",
- "label": "",
- "value": true
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 375,
- "y": 0
- }
- },
- {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "type": "invocation",
- "data": {
- "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
- "version": "1.0.1",
- "nodePack": "invokeai",
- "label": "Random Seed",
- "notes": "",
- "type": "rand_int",
- "inputs": {
- "low": {
- "name": "low",
- "label": "",
- "value": 0
- },
- "high": {
- "name": "high",
- "label": "",
- "value": 2147483647
- }
- },
- "isOpen": false,
- "isIntermediate": true,
- "useCache": false
- },
- "position": {
- "x": 375,
- "y": -50
+ "y": -175
}
},
{
@@ -234,7 +188,7 @@
"type": "invocation",
"data": {
"id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22",
- "version": "1.0.2",
+ "version": "1.0.3",
"nodePack": "invokeai",
"label": "",
"notes": "",
@@ -242,14 +196,7 @@
"inputs": {
"model": {
"name": "model",
- "label": "",
- "value": {
- "key": "4a63b226-e8ff-4da4-854e-0b9f04b562ba",
- "hash": "blake3:d279309ea6e5ee6e8fd52504275865cc280dac71cbf528c5b07c98b888bddaba",
- "name": "dreamshaper-xl-v2-turbo",
- "base": "sdxl",
- "type": "main"
- }
+ "label": ""
}
},
"isOpen": true,
@@ -262,24 +209,24 @@
}
},
{
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
+ "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
"type": "invocation",
"data": {
- "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08",
- "version": "1.1.1",
+ "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204",
+ "version": "1.2.0",
"nodePack": "invokeai",
- "label": "SDXL Positive Compel Prompt",
+ "label": "SDXL Negative Compel Prompt",
"notes": "",
"type": "sdxl_compel_prompt",
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Positive Prompt",
+ "label": "Negative Prompt",
"value": ""
},
"style": {
"name": "style",
- "label": "Positive Style",
+ "label": "Negative Style",
"value": ""
},
"original_width": {
@@ -319,6 +266,10 @@
"clip2": {
"name": "clip2",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": false,
@@ -327,54 +278,134 @@
},
"position": {
"x": 750,
- "y": -175
+ "y": 200
}
},
{
- "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
+ "id": "3774ec24-a69e-4254-864c-097d07a6256f",
"type": "invocation",
"data": {
- "id": "63e91020-83b2-4f35-b174-ad9692aabb48",
- "version": "1.2.2",
+ "id": "3774ec24-a69e-4254-864c-097d07a6256f",
+ "version": "1.0.1",
+ "label": "Positive Style Concat",
+ "notes": "",
+ "type": "string_join",
+ "inputs": {
+ "string_left": {
+ "name": "string_left",
+ "label": "",
+ "value": ""
+ },
+ "string_right": {
+ "name": "string_right",
+ "label": "Positive Style Concat",
+ "value": ""
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 750,
+ "y": -225
+ }
+ },
+ {
+ "id": "719dabe8-8297-4749-aea1-37be301cd425",
+ "type": "invocation",
+ "data": {
+ "id": "719dabe8-8297-4749-aea1-37be301cd425",
+ "version": "1.0.1",
+ "label": "Negative Prompt",
+ "notes": "",
+ "type": "string",
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "Negative Prompt",
+ "value": "photograph"
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 750,
+ "y": -125
+ }
+ },
+ {
+ "id": "55705012-79b9-4aac-9f26-c0b10309785b",
+ "type": "invocation",
+ "data": {
+ "id": "55705012-79b9-4aac-9f26-c0b10309785b",
+ "version": "1.0.2",
"nodePack": "invokeai",
"label": "",
"notes": "",
- "type": "l2i",
+ "type": "noise",
"inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
+ "seed": {
+ "name": "seed",
+ "label": "",
+ "value": 0
},
- "vae": {
- "name": "vae",
- "label": ""
+ "width": {
+ "name": "width",
+ "label": "",
+ "value": 1024
},
- "tiled": {
- "name": "tiled",
+ "height": {
+ "name": "height",
"label": "",
- "value": false
+ "value": 1024
},
- "fp32": {
- "name": "fp32",
+ "use_cpu": {
+ "name": "use_cpu",
"label": "",
- "value": false
+ "value": true
}
},
"isOpen": true,
- "isIntermediate": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 375,
+ "y": 0
+ }
+ },
+ {
+ "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
+ "type": "invocation",
+ "data": {
+ "id": "ea94bc37-d995-4a83-aa99-4af42479f2f2",
+ "version": "1.0.1",
+ "nodePack": "invokeai",
+ "label": "Random Seed",
+ "notes": "",
+ "type": "rand_int",
+ "inputs": {
+ "low": {
+ "name": "low",
+ "label": "",
+ "value": 0
+ },
+ "high": {
+ "name": "high",
+ "label": "",
+ "value": 2147483647
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
"useCache": false
},
"position": {
- "x": 1475,
- "y": -500
+ "x": 375,
+ "y": -50
}
},
{
@@ -464,37 +495,6 @@
"y": -500
}
},
- {
- "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "type": "invocation",
- "data": {
- "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8",
- "version": "1.0.2",
- "label": "",
- "notes": "",
- "type": "vae_loader",
- "inputs": {
- "vae_model": {
- "name": "vae_model",
- "label": "VAE (use the FP16 model)",
- "value": {
- "key": "f20f9e5c-1bce-4c46-a84d-34ebfa7df069",
- "hash": "blake3:9705ab1c31fa96b308734214fb7571a958621c7a9247eed82b7d277145f8d9fa",
- "name": "sdxl-vae-fp16-fix",
- "base": "sdxl",
- "type": "vae"
- }
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": 375,
- "y": -225
- }
- },
{
"id": "ade2c0d3-0384-4157-b39b-29ce429cfa15",
"type": "invocation",
@@ -701,4 +701,4 @@
"targetHandle": "style"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json
index 6df02b675de..ca1b0bc8793 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json
+++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json
@@ -1,10 +1,11 @@
{
- "name": "Text to Image with LoRA",
+ "id": "default_e71d153c-2089-43c7-bd2c-f61f37d4c1c1",
+ "name": "Text to Image - SD1.5, LoRA",
"author": "InvokeAI",
"description": "Simple text to image workflow with a LoRA",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "text to image, lora, default",
+ "tags": "sd1.5, text to image, lora",
"notes": "",
"exposedFields": [
{
@@ -37,51 +38,82 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
{
- "id": "85b77bb2-c67a-416a-b3e8-291abe746c44",
+ "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400",
"type": "invocation",
"data": {
- "id": "85b77bb2-c67a-416a-b3e8-291abe746c44",
- "version": "1.1.1",
+ "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400",
+ "version": "1.3.0",
"label": "",
"notes": "",
- "type": "compel",
+ "type": "l2i",
"inputs": {
- "prompt": {
- "name": "prompt",
- "label": "Negative Prompt",
- "value": ""
+ "board": {
+ "name": "board",
+ "label": ""
},
- "clip": {
- "name": "clip",
+ "metadata": {
+ "name": "metadata",
"label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
+ },
+ "tile_size": {
+ "name": "tile_size",
+ "label": "",
+ "value": 0
+ },
+ "fp32": {
+ "name": "fp32",
+ "label": "",
+ "value": false
}
},
"isOpen": true,
- "isIntermediate": true,
+ "isIntermediate": false,
"useCache": true
},
"position": {
- "x": 3425,
- "y": -300
+ "x": 4450,
+ "y": -550
}
},
{
- "id": "24e9d7ed-4836-4ec4-8f9e-e747721f9818",
+ "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b",
"type": "invocation",
"data": {
- "id": "24e9d7ed-4836-4ec4-8f9e-e747721f9818",
- "version": "1.0.2",
+ "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b",
+ "version": "1.2.0",
"label": "",
"notes": "",
- "type": "main_model_loader",
+ "type": "compel",
"inputs": {
- "model": {
- "name": "model",
+ "prompt": {
+ "name": "prompt",
+ "label": "Positive Prompt",
+ "value": "super cute tiger cub"
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
"label": ""
}
},
@@ -90,8 +122,8 @@
"useCache": true
},
"position": {
- "x": 2500,
- "y": -600
+ "x": 3425,
+ "y": -575
}
},
{
@@ -99,7 +131,7 @@
"type": "invocation",
"data": {
"id": "c41e705b-f2e3-4d1a-83c4-e34bb9344966",
- "version": "1.0.2",
+ "version": "1.0.3",
"label": "",
"notes": "",
"type": "lora_loader",
@@ -132,23 +164,51 @@
}
},
{
- "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b",
+ "id": "24e9d7ed-4836-4ec4-8f9e-e747721f9818",
"type": "invocation",
"data": {
- "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b",
- "version": "1.1.1",
+ "id": "24e9d7ed-4836-4ec4-8f9e-e747721f9818",
+ "version": "1.0.3",
+ "label": "",
+ "notes": "",
+ "type": "main_model_loader",
+ "inputs": {
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": 2500,
+ "y": -600
+ }
+ },
+ {
+ "id": "85b77bb2-c67a-416a-b3e8-291abe746c44",
+ "type": "invocation",
+ "data": {
+ "id": "85b77bb2-c67a-416a-b3e8-291abe746c44",
+ "version": "1.2.0",
"label": "",
"notes": "",
"type": "compel",
"inputs": {
"prompt": {
"name": "prompt",
- "label": "Positive Prompt",
- "value": "super cute tiger cub"
+ "label": "Negative Prompt",
+ "value": ""
},
"clip": {
"name": "clip",
"label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
"isOpen": true,
@@ -157,7 +217,7 @@
},
"position": {
"x": 3425,
- "y": -575
+ "y": -300
}
},
{
@@ -315,52 +375,6 @@
"x": 3425,
"y": 0
}
- },
- {
- "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400",
- "type": "invocation",
- "data": {
- "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400",
- "version": "1.2.2",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": true,
- "isIntermediate": false,
- "useCache": true
- },
- "position": {
- "x": 4450,
- "y": -550
- }
}
],
"edges": [
diff --git a/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json b/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json
index bb0e9062e48..7bc96cd911f 100644
--- a/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json
+++ b/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json
@@ -1,10 +1,11 @@
{
- "name": "Tiled Upscaling (Beta)",
+ "id": "default_43b0d7f7-6a12-4dcf-a5a4-50c940cbee29",
+ "name": "Upscaler - SD1.5, Tiled",
"author": "Invoke",
"description": "A workflow to upscale an input image with tiled upscaling. ",
- "version": "2.0.0",
+ "version": "2.1.0",
"contact": "invoke@invoke.ai",
- "tags": "tiled, upscaling, sd1.5",
+ "tags": "sd1.5, upscaling",
"notes": "",
"exposedFields": [
{
@@ -41,93 +42,184 @@
}
],
"meta": {
- "category": "default",
- "version": "3.0.0"
+ "version": "3.0.0",
+ "category": "default"
},
"nodes": [
{
- "id": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a",
+ "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5",
"type": "invocation",
"data": {
- "id": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a",
- "version": "1.0.1",
- "label": "Creativity Input",
+ "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5",
+ "version": "1.0.3",
+ "label": "",
"notes": "",
- "type": "float_math",
+ "type": "main_model_loader",
"inputs": {
- "operation": {
- "name": "operation",
+ "model": {
+ "name": "model",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": -4514.466823162653,
+ "y": -1235.7908800002283
+ }
+ },
+ {
+ "id": "287f134f-da8d-41d1-884e-5940e8f7b816",
+ "type": "invocation",
+ "data": {
+ "id": "287f134f-da8d-41d1-884e-5940e8f7b816",
+ "version": "1.4.1",
+ "label": "",
+ "notes": "",
+ "type": "ip_adapter",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": ""
+ },
+ "ip_adapter_model": {
+ "name": "ip_adapter_model",
+ "label": "IP-Adapter Model (select ip_adapter_sd15)"
+ },
+ "clip_vision_model": {
+ "name": "clip_vision_model",
"label": "",
- "value": "DIV"
+ "value": "ViT-H"
},
- "a": {
- "name": "a",
- "label": "Creativity",
- "value": 0.3
+ "weight": {
+ "name": "weight",
+ "label": "",
+ "value": 0.2
},
- "b": {
- "name": "b",
+ "method": {
+ "name": "method",
"label": "",
- "value": 3.3
+ "value": "full"
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "",
+ "value": 1
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
}
},
- "isOpen": false,
+ "isOpen": true,
"isIntermediate": true,
"useCache": true
},
"position": {
- "x": -4007.507843708216,
- "y": -621.6878478530825
+ "x": -2855.8555540799207,
+ "y": -183.58854843775742
}
},
{
- "id": "7dbb756b-7d79-431c-a46d-d8f7b082c127",
+ "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c",
"type": "invocation",
"data": {
- "id": "7dbb756b-7d79-431c-a46d-d8f7b082c127",
- "version": "1.0.1",
+ "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c",
+ "version": "1.3.0",
"label": "",
"notes": "",
- "type": "float_to_int",
+ "type": "l2i",
"inputs": {
- "value": {
- "name": "value",
+ "board": {
+ "name": "board",
+ "label": ""
+ },
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "latents": {
+ "name": "latents",
+ "label": ""
+ },
+ "vae": {
+ "name": "vae",
+ "label": ""
+ },
+ "tiled": {
+ "name": "tiled",
"label": "",
- "value": 0
+ "value": false
},
- "multiple": {
- "name": "multiple",
+ "tile_size": {
+ "name": "tile_size",
"label": "",
- "value": 8
+ "value": 0
},
- "method": {
- "name": "method",
+ "fp32": {
+ "name": "fp32",
"label": "",
- "value": "Floor"
+ "value": false
}
},
- "isOpen": false,
+ "isOpen": true,
"isIntermediate": true,
"useCache": true
},
"position": {
- "x": -4470.518114882552,
- "y": -246.9687512362472
+ "x": -1999.770193862987,
+ "y": -1075
}
},
{
- "id": "5ca87ace-edf9-49c7-a424-cd42416b86a7",
+ "id": "d334f2da-016a-4524-9911-bdab85546888",
"type": "invocation",
"data": {
- "id": "5ca87ace-edf9-49c7-a424-cd42416b86a7",
- "version": "1.0.2",
+ "id": "d334f2da-016a-4524-9911-bdab85546888",
+ "version": "1.1.2",
"label": "",
"notes": "",
- "type": "image",
+ "type": "controlnet",
"inputs": {
"image": {
"name": "image",
- "label": "Image to Upscale"
+ "label": ""
+ },
+ "control_model": {
+ "name": "control_model",
+ "label": "Control Model (select control_v11f1e_sd15_tile)"
+ },
+ "control_weight": {
+ "name": "control_weight",
+ "label": "",
+ "value": 1
+ },
+ "begin_step_percent": {
+ "name": "begin_step_percent",
+ "label": "",
+ "value": 0
+ },
+ "end_step_percent": {
+ "name": "end_step_percent",
+ "label": "Structural Control",
+ "value": 1
+ },
+ "control_mode": {
+ "name": "control_mode",
+ "label": "",
+ "value": "more_control"
+ },
+ "resize_mode": {
+ "name": "resize_mode",
+ "label": "",
+ "value": "just_resize"
}
},
"isOpen": true,
@@ -135,41 +227,42 @@
"useCache": true
},
"position": {
- "x": -4485.384246996007,
- "y": -977.6662925348955
+ "x": -2481.9569385477016,
+ "y": -181.06590482739782
}
},
{
- "id": "fad15012-0787-43a8-99dd-27f1518b5bc7",
+ "id": "338b883c-3728-4f18-b3a6-6e7190c2f850",
"type": "invocation",
"data": {
- "id": "fad15012-0787-43a8-99dd-27f1518b5bc7",
- "version": "1.2.2",
+ "id": "338b883c-3728-4f18-b3a6-6e7190c2f850",
+ "version": "1.1.0",
"label": "",
"notes": "",
- "type": "img_scale",
+ "type": "i2l",
"inputs": {
- "board": {
- "name": "board",
+ "image": {
+ "name": "image",
"label": ""
},
- "metadata": {
- "name": "metadata",
+ "vae": {
+ "name": "vae",
"label": ""
},
- "image": {
- "name": "image",
- "label": ""
+ "tiled": {
+ "name": "tiled",
+ "label": "",
+ "value": false
},
- "scale_factor": {
- "name": "scale_factor",
+ "tile_size": {
+ "name": "tile_size",
"label": "",
- "value": 3
+ "value": 0
},
- "resample_mode": {
- "name": "resample_mode",
+ "fp32": {
+ "name": "fp32",
"label": "",
- "value": "lanczos"
+ "value": false
}
},
"isOpen": false,
@@ -177,8 +270,41 @@
"useCache": true
},
"position": {
- "x": -4478.200192078582,
- "y": 3.422855503409039
+ "x": -2908.4791167517287,
+ "y": -408.87504820159086
+ }
+ },
+ {
+ "id": "947c3f88-0305-4695-8355-df4abac64b1c",
+ "type": "invocation",
+ "data": {
+ "id": "947c3f88-0305-4695-8355-df4abac64b1c",
+ "version": "1.2.0",
+ "label": "",
+ "notes": "",
+ "type": "compel",
+ "inputs": {
+ "prompt": {
+ "name": "prompt",
+ "label": "",
+ "value": ""
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": -4014.4136788915944,
+ "y": -968.5677253775948
}
},
{
@@ -186,19 +312,117 @@
"type": "invocation",
"data": {
"id": "9b2d8c58-ce8f-4162-a5a1-48de854040d6",
- "version": "1.1.1",
+ "version": "1.2.0",
"label": "",
"notes": "",
"type": "compel",
"inputs": {
- "prompt": {
- "name": "prompt",
- "label": "Positive Prompt",
- "value": ""
- },
- "clip": {
- "name": "clip",
- "label": ""
+ "prompt": {
+ "name": "prompt",
+ "label": "Positive Prompt",
+ "value": ""
+ },
+ "clip": {
+ "name": "clip",
+ "label": ""
+ },
+ "mask": {
+ "name": "mask",
+ "label": ""
+ }
+ },
+ "isOpen": true,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": -4014.4136788915944,
+ "y": -1243.5677253775948
+ }
+ },
+ {
+ "id": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a",
+ "type": "invocation",
+ "data": {
+ "id": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a",
+ "version": "1.0.1",
+ "label": "Creativity Input",
+ "notes": "",
+ "type": "float_math",
+ "inputs": {
+ "operation": {
+ "name": "operation",
+ "label": "",
+ "value": "DIV"
+ },
+ "a": {
+ "name": "a",
+ "label": "Creativity",
+ "value": 0.3
+ },
+ "b": {
+ "name": "b",
+ "label": "",
+ "value": 3.3
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": -4007.507843708216,
+ "y": -621.6878478530825
+ }
+ },
+ {
+ "id": "7dbb756b-7d79-431c-a46d-d8f7b082c127",
+ "type": "invocation",
+ "data": {
+ "id": "7dbb756b-7d79-431c-a46d-d8f7b082c127",
+ "version": "1.0.1",
+ "label": "",
+ "notes": "",
+ "type": "float_to_int",
+ "inputs": {
+ "value": {
+ "name": "value",
+ "label": "",
+ "value": 0
+ },
+ "multiple": {
+ "name": "multiple",
+ "label": "",
+ "value": 8
+ },
+ "method": {
+ "name": "method",
+ "label": "",
+ "value": "Floor"
+ }
+ },
+ "isOpen": false,
+ "isIntermediate": true,
+ "useCache": true
+ },
+ "position": {
+ "x": -4470.518114882552,
+ "y": -246.9687512362472
+ }
+ },
+ {
+ "id": "5ca87ace-edf9-49c7-a424-cd42416b86a7",
+ "type": "invocation",
+ "data": {
+ "id": "5ca87ace-edf9-49c7-a424-cd42416b86a7",
+ "version": "1.0.2",
+ "label": "",
+ "notes": "",
+ "type": "image",
+ "inputs": {
+ "image": {
+ "name": "image",
+ "label": "Image to Upscale"
}
},
"isOpen": true,
@@ -206,37 +430,50 @@
"useCache": true
},
"position": {
- "x": -4014.4136788915944,
- "y": -1243.5677253775948
+ "x": -4485.384246996007,
+ "y": -977.6662925348955
}
},
{
- "id": "947c3f88-0305-4695-8355-df4abac64b1c",
+ "id": "fad15012-0787-43a8-99dd-27f1518b5bc7",
"type": "invocation",
"data": {
- "id": "947c3f88-0305-4695-8355-df4abac64b1c",
- "version": "1.1.1",
+ "id": "fad15012-0787-43a8-99dd-27f1518b5bc7",
+ "version": "1.2.2",
"label": "",
"notes": "",
- "type": "compel",
+ "type": "img_scale",
"inputs": {
- "prompt": {
- "name": "prompt",
- "label": "",
- "value": ""
+ "board": {
+ "name": "board",
+ "label": ""
},
- "clip": {
- "name": "clip",
+ "metadata": {
+ "name": "metadata",
+ "label": ""
+ },
+ "image": {
+ "name": "image",
"label": ""
+ },
+ "scale_factor": {
+ "name": "scale_factor",
+ "label": "",
+ "value": 3
+ },
+ "resample_mode": {
+ "name": "resample_mode",
+ "label": "",
+ "value": "lanczos"
}
},
- "isOpen": true,
+ "isOpen": false,
"isIntermediate": true,
"useCache": true
},
"position": {
- "x": -4014.4136788915944,
- "y": -968.5677253775948
+ "x": -4478.200192078582,
+ "y": 3.422855503409039
}
},
{
@@ -379,104 +616,6 @@
"y": -29.08699277598673
}
},
- {
- "id": "338b883c-3728-4f18-b3a6-6e7190c2f850",
- "type": "invocation",
- "data": {
- "id": "338b883c-3728-4f18-b3a6-6e7190c2f850",
- "version": "1.0.2",
- "label": "",
- "notes": "",
- "type": "i2l",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": false,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": -2908.4791167517287,
- "y": -408.87504820159086
- }
- },
- {
- "id": "d334f2da-016a-4524-9911-bdab85546888",
- "type": "invocation",
- "data": {
- "id": "d334f2da-016a-4524-9911-bdab85546888",
- "version": "1.1.1",
- "label": "",
- "notes": "",
- "type": "controlnet",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "control_model": {
- "name": "control_model",
- "label": "Control Model (select contro_v11f1e_sd15_tile)",
- "value": {
- "key": "773843c8-db1f-4502-8f65-59782efa7960",
- "hash": "blake3:f0812e13758f91baf4e54b7dbb707b70642937d3b2098cd2b94cc36d3eba308e",
- "name": "control_v11f1e_sd15_tile",
- "base": "sd-1",
- "type": "controlnet"
- }
- },
- "control_weight": {
- "name": "control_weight",
- "label": "",
- "value": 1
- },
- "begin_step_percent": {
- "name": "begin_step_percent",
- "label": "",
- "value": 0
- },
- "end_step_percent": {
- "name": "end_step_percent",
- "label": "Structural Control",
- "value": 1
- },
- "control_mode": {
- "name": "control_mode",
- "label": "",
- "value": "more_control"
- },
- "resize_mode": {
- "name": "resize_mode",
- "label": "",
- "value": "just_resize"
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": -2481.9569385477016,
- "y": -181.06590482739782
- }
- },
{
"id": "1011539e-85de-4e02-a003-0b22358491b8",
"type": "invocation",
@@ -563,52 +702,6 @@
"y": -1006.415909408244
}
},
- {
- "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c",
- "type": "invocation",
- "data": {
- "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c",
- "version": "1.2.2",
- "label": "",
- "notes": "",
- "type": "l2i",
- "inputs": {
- "board": {
- "name": "board",
- "label": ""
- },
- "metadata": {
- "name": "metadata",
- "label": ""
- },
- "latents": {
- "name": "latents",
- "label": ""
- },
- "vae": {
- "name": "vae",
- "label": ""
- },
- "tiled": {
- "name": "tiled",
- "label": "",
- "value": false
- },
- "fp32": {
- "name": "fp32",
- "label": "",
- "value": false
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": -1999.770193862987,
- "y": -1075
- }
- },
{
"id": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527",
"type": "invocation",
@@ -779,56 +872,6 @@
"y": -78.2819050861178
}
},
- {
- "id": "287f134f-da8d-41d1-884e-5940e8f7b816",
- "type": "invocation",
- "data": {
- "id": "287f134f-da8d-41d1-884e-5940e8f7b816",
- "version": "1.2.2",
- "label": "",
- "notes": "",
- "type": "ip_adapter",
- "inputs": {
- "image": {
- "name": "image",
- "label": ""
- },
- "ip_adapter_model": {
- "name": "ip_adapter_model",
- "label": "IP-Adapter Model (select ip_adapter_sd15)",
- "value": {
- "key": "1cc210bb-4d0a-4312-b36c-b5d46c43768e",
- "hash": "blake3:3d669dffa7471b357b4df088b99ffb6bf4d4383d5e0ef1de5ec1c89728a3d5a5",
- "name": "ip_adapter_sd15",
- "base": "sd-1",
- "type": "ip_adapter"
- }
- },
- "weight": {
- "name": "weight",
- "label": "",
- "value": 0.2
- },
- "begin_step_percent": {
- "name": "begin_step_percent",
- "label": "",
- "value": 0
- },
- "end_step_percent": {
- "name": "end_step_percent",
- "label": "",
- "value": 1
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": -2855.8555540799207,
- "y": -183.58854843775742
- }
- },
{
"id": "1f86c8bf-06f9-4e28-abee-02f46f445ac4",
"type": "invocation",
@@ -899,30 +942,6 @@
"y": -41.810810454906914
}
},
- {
- "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5",
- "type": "invocation",
- "data": {
- "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5",
- "version": "1.0.2",
- "label": "",
- "notes": "",
- "type": "main_model_loader",
- "inputs": {
- "model": {
- "name": "model",
- "label": ""
- }
- },
- "isOpen": true,
- "isIntermediate": true,
- "useCache": true
- },
- "position": {
- "x": -4514.466823162653,
- "y": -1235.7908800002283
- }
- },
{
"id": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea",
"type": "invocation",
@@ -1783,4 +1802,4 @@
"targetHandle": "unet"
}
]
-}
\ No newline at end of file
+}
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index 499b0f005d1..c07daa2662e 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -4,6 +4,7 @@
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowRecordDTO,
@@ -22,29 +23,81 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
pass
@abstractmethod
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
+ def create(
+ self,
+ workflow: WorkflowWithoutID,
+ user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
+ is_public: bool = False,
+ ) -> WorkflowRecordDTO:
"""Creates a workflow."""
pass
@abstractmethod
- def update(self, workflow: Workflow) -> WorkflowRecordDTO:
- """Updates a workflow."""
+ def update(self, workflow: Workflow, user_id: Optional[str] = None) -> WorkflowRecordDTO:
+ """Updates a workflow. When user_id is provided, the UPDATE is scoped to that user."""
pass
@abstractmethod
- def delete(self, workflow_id: str) -> None:
- """Deletes a workflow."""
+ def delete(self, workflow_id: str, user_id: Optional[str] = None) -> None:
+ """Deletes a workflow. When user_id is provided, the DELETE is scoped to that user."""
pass
@abstractmethod
def get_many(
self,
- page: int,
- per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
- category: WorkflowCategory,
+ categories: Optional[list[WorkflowCategory]],
+ page: int,
+ per_page: Optional[int],
query: Optional[str],
+ tags: Optional[list[str]],
+ has_been_opened: Optional[bool],
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
+
+ @abstractmethod
+ def counts_by_category(
+ self,
+ categories: list[WorkflowCategory],
+ has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> dict[str, int]:
+ """Gets a dictionary of counts for each of the provided categories."""
+ pass
+
+ @abstractmethod
+ def counts_by_tag(
+ self,
+ tags: list[str],
+ categories: Optional[list[WorkflowCategory]] = None,
+ has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> dict[str, int]:
+ """Gets a dictionary of counts for each of the provided tags."""
+ pass
+
+ @abstractmethod
+ def update_opened_at(self, workflow_id: str, user_id: Optional[str] = None) -> None:
+ """Open a workflow. When user_id is provided, the UPDATE is scoped to that user."""
+ pass
+
+ @abstractmethod
+ def get_all_tags(
+ self,
+ categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> list[str]:
+ """Gets all unique tags from workflows."""
+ pass
+
+ @abstractmethod
+ def update_is_public(self, workflow_id: str, is_public: bool, user_id: Optional[str] = None) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow. When user_id is provided, the UPDATE is scoped to that user."""
+ pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index e02600a0c35..9c505530c90 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -1,6 +1,6 @@
import datetime
from enum import Enum
-from typing import Any, Union
+from typing import Any, Optional, Union
import semver
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, field_validator
@@ -9,6 +9,9 @@
__workflow_meta_version__ = semver.Version.parse("1.0.0")
+WORKFLOW_LIBRARY_DEFAULT_USER_ID = "system"
+"""Default user_id for workflows created in single-user mode or migrated from pre-multiuser databases."""
+
class ExposedField(BaseModel):
nodeId: str
@@ -26,19 +29,17 @@ class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum):
UpdatedAt = "updated_at"
OpenedAt = "opened_at"
Name = "name"
+ IsPublic = "is_public"
class WorkflowCategory(str, Enum, metaclass=MetaEnum):
User = "user"
Default = "default"
- Project = "project"
class WorkflowMeta(BaseModel):
version: str = Field(description="The version of the workflow schema.")
- category: WorkflowCategory = Field(
- default=WorkflowCategory.User, description="The category of the workflow (user or default)."
- )
+ category: WorkflowCategory = Field(description="The category of the workflow (user or default).")
@field_validator("version")
def validate_version(cls, version: str):
@@ -62,9 +63,13 @@ class WorkflowWithoutID(BaseModel):
notes: str = Field(description="The notes of the workflow.")
exposedFields: list[ExposedField] = Field(description="The exposed fields of the workflow.")
meta: WorkflowMeta = Field(description="The meta of the workflow.")
- # TODO: nodes and edges are very loosely typed
+ # TODO(psyche): nodes, edges and form are very loosely typed - they are strictly modeled and checked on the frontend.
nodes: list[dict[str, JsonValue]] = Field(description="The nodes of the workflow.")
edges: list[dict[str, JsonValue]] = Field(description="The edges of the workflow.")
+ # TODO(psyche): We have a crapload of workflows that have no form, bc it was added after we introduced workflows.
+ # This is typed as optional to prevent errors when pulling workflows from the DB. The frontend adds a default form if
+ # it is None.
+ form: dict[str, JsonValue] | None = Field(default=None, description="The form of the workflow.")
model_config = ConfigDict(extra="ignore")
@@ -96,7 +101,11 @@ class WorkflowRecordDTOBase(BaseModel):
name: str = Field(description="The name of the workflow.")
created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.")
updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.")
- opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.")
+ opened_at: Optional[Union[datetime.datetime, str]] = Field(
+ default=None, description="The opened timestamp of the workflow."
+ )
+ user_id: str = Field(description="The id of the user who owns this workflow.")
+ is_public: bool = Field(description="Whether this workflow is shared with all users.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):
@@ -114,6 +123,15 @@ def from_dict(cls, data: dict[str, Any]) -> "WorkflowRecordDTO":
class WorkflowRecordListItemDTO(WorkflowRecordDTOBase):
description: str = Field(description="The description of the workflow.")
category: WorkflowCategory = Field(description="The description of the workflow.")
+ tags: str = Field(description="The tags of the workflow.")
WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO)
+
+
+class WorkflowRecordWithThumbnailDTO(WorkflowRecordDTO):
+ thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.")
+
+
+class WorkflowRecordListItemWithThumbnailDTO(WorkflowRecordListItemDTO):
+ thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.")
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index af0bad8260f..a62dbb9dfa8 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -7,6 +7,7 @@
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowNotFoundError,
@@ -14,18 +15,18 @@
WorkflowRecordListItemDTO,
WorkflowRecordListItemDTOValidator,
WorkflowRecordOrderBy,
+ WorkflowValidator,
WorkflowWithoutID,
- WorkflowWithoutIDValidator,
)
from invokeai.app.util.misc import uuid_string
+SQL_TIME_FORMAT = "%Y-%m-%d %H:%M:%f"
+
class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
- self._lock = db.lock
- self._conn = db.conn
- self._cursor = self._conn.cursor()
+ self._db = db
def start(self, invoker: Invoker) -> None:
self._invoker = invoker
@@ -33,156 +34,467 @@ def start(self, invoker: Invoker) -> None:
def get(self, workflow_id: str) -> WorkflowRecordDTO:
"""Gets a workflow by ID. Updates the opened_at column."""
- try:
- self._lock.acquire()
- self._cursor.execute(
+ with self._db.transaction() as cursor:
+ cursor.execute(
"""--sql
- UPDATE workflow_library
- SET opened_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
- WHERE workflow_id = ?;
- """,
- (workflow_id,),
- )
- self._conn.commit()
- self._cursor.execute(
- """--sql
- SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
+ SELECT workflow_id, workflow, name, created_at, updated_at, opened_at, user_id, is_public
FROM workflow_library
WHERE workflow_id = ?;
""",
(workflow_id,),
)
- row = self._cursor.fetchone()
- if row is None:
- raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
- return WorkflowRecordDTO.from_dict(dict(row))
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
-
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
- try:
- # Only user workflows may be created by this method
- assert workflow.meta.category is WorkflowCategory.User
+ row = cursor.fetchone()
+ if row is None:
+ raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
+ return WorkflowRecordDTO.from_dict(dict(row))
+
+ def create(
+ self,
+ workflow: WorkflowWithoutID,
+ user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
+ is_public: bool = False,
+ ) -> WorkflowRecordDTO:
+ if workflow.meta.category is WorkflowCategory.Default:
+ raise ValueError("Default workflows cannot be created via this method")
+
+ with self._db.transaction() as cursor:
workflow_with_id = Workflow(**workflow.model_dump(), id=uuid_string())
- self._lock.acquire()
- self._cursor.execute(
+ cursor.execute(
"""--sql
INSERT OR IGNORE INTO workflow_library (
workflow_id,
- workflow
+ workflow,
+ user_id,
+ is_public
)
- VALUES (?, ?);
+ VALUES (?, ?, ?, ?);
""",
- (workflow_with_id.id, workflow_with_id.model_dump_json()),
+ (workflow_with_id.id, workflow_with_id.model_dump_json(), user_id, is_public),
)
- self._conn.commit()
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
return self.get(workflow_with_id.id)
- def update(self, workflow: Workflow) -> WorkflowRecordDTO:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- UPDATE workflow_library
- SET workflow = ?
- WHERE workflow_id = ? AND category = 'user';
- """,
- (workflow.model_dump_json(), workflow.id),
- )
- self._conn.commit()
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
+ def update(self, workflow: Workflow, user_id: Optional[str] = None) -> WorkflowRecordDTO:
+ if workflow.meta.category is WorkflowCategory.Default:
+ raise ValueError("Default workflows cannot be updated")
+
+ with self._db.transaction() as cursor:
+ if user_id is not None:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?
+ WHERE workflow_id = ? AND category = 'user' AND user_id = ?;
+ """,
+ (workflow.model_dump_json(), workflow.id, user_id),
+ )
+ else:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?
+ WHERE workflow_id = ? AND category = 'user';
+ """,
+ (workflow.model_dump_json(), workflow.id),
+ )
return self.get(workflow.id)
- def delete(self, workflow_id: str) -> None:
- try:
- self._lock.acquire()
- self._cursor.execute(
- """--sql
- DELETE from workflow_library
- WHERE workflow_id = ? AND category = 'user';
- """,
- (workflow_id,),
- )
- self._conn.commit()
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
+ def delete(self, workflow_id: str, user_id: Optional[str] = None) -> None:
+ if self.get(workflow_id).workflow.meta.category is WorkflowCategory.Default:
+ raise ValueError("Default workflows cannot be deleted")
+
+ with self._db.transaction() as cursor:
+ if user_id is not None:
+ cursor.execute(
+ """--sql
+ DELETE from workflow_library
+ WHERE workflow_id = ? AND category = 'user' AND user_id = ?;
+ """,
+ (workflow_id, user_id),
+ )
+ else:
+ cursor.execute(
+ """--sql
+ DELETE from workflow_library
+ WHERE workflow_id = ? AND category = 'user';
+ """,
+ (workflow_id,),
+ )
return None
+ def update_is_public(self, workflow_id: str, is_public: bool, user_id: Optional[str] = None) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow and manages the 'shared' tag automatically."""
+ record = self.get(workflow_id)
+ workflow = record.workflow
+
+ # Manage "shared" tag: add when public, remove when private
+ tags_list = [t.strip() for t in workflow.tags.split(",") if t.strip()] if workflow.tags else []
+ if is_public and "shared" not in tags_list:
+ tags_list.append("shared")
+ elif not is_public and "shared" in tags_list:
+ tags_list.remove("shared")
+ updated_tags = ", ".join(tags_list)
+ updated_workflow = workflow.model_copy(update={"tags": updated_tags})
+
+ with self._db.transaction() as cursor:
+ if user_id is not None:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?, is_public = ?
+ WHERE workflow_id = ? AND category = 'user' AND user_id = ?;
+ """,
+ (updated_workflow.model_dump_json(), is_public, workflow_id, user_id),
+ )
+ else:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?, is_public = ?
+ WHERE workflow_id = ? AND category = 'user';
+ """,
+ (updated_workflow.model_dump_json(), is_public, workflow_id),
+ )
+ return self.get(workflow_id)
+
def get_many(
self,
- page: int,
- per_page: int,
order_by: WorkflowRecordOrderBy,
direction: SQLiteDirection,
- category: WorkflowCategory,
+ categories: Optional[list[WorkflowCategory]],
+ page: int = 0,
+ per_page: Optional[int] = None,
query: Optional[str] = None,
+ tags: Optional[list[str]] = None,
+ has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
- try:
- self._lock.acquire()
+ with self._db.transaction() as cursor:
# sanitize!
assert order_by in WorkflowRecordOrderBy
assert direction in SQLiteDirection
- assert category in WorkflowCategory
- count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
+
+ # We will construct the query dynamically based on the query params
+
+ # The main query to get the workflows / counts
main_query = """
- SELECT
- workflow_id,
- category,
- name,
- description,
- created_at,
- updated_at,
- opened_at
- FROM workflow_library
- WHERE category = ?
- """
- main_params: list[int | str] = [category.value]
- count_params: list[int | str] = [category.value]
+ SELECT
+ workflow_id,
+ category,
+ name,
+ description,
+ created_at,
+ updated_at,
+ opened_at,
+ tags,
+ user_id,
+ is_public
+ FROM workflow_library
+ """
+ count_query = "SELECT COUNT(*) FROM workflow_library"
+
+ # Start with an empty list of conditions and params
+ conditions: list[str] = []
+ params: list[str | int] = []
+
+ if categories:
+ # Categories is a list of WorkflowCategory enum values, and a single string in the DB
+
+ # Ensure all categories are valid (is this necessary?)
+ assert all(c in WorkflowCategory for c in categories)
+
+ # Construct a placeholder string for the number of categories
+ placeholders = ", ".join("?" for _ in categories)
+
+ # Construct the condition string & params
+ category_condition = f"category IN ({placeholders})"
+ category_params = [category.value for category in categories]
+
+ conditions.append(category_condition)
+ params.extend(category_params)
+
+ if tags:
+ # Tags is a list of strings, and a single string in the DB
+ # The string in the DB has no guaranteed format
+
+ # Construct a list of conditions for each tag
+ tags_conditions = ["tags LIKE ?" for _ in tags]
+ tags_conditions_joined = " OR ".join(tags_conditions)
+ tags_condition = f"({tags_conditions_joined})"
+
+ # And the params for the tags, case-insensitive
+ tags_params = [f"%{t.strip()}%" for t in tags]
+
+ conditions.append(tags_condition)
+ params.extend(tags_params)
+
+ if has_been_opened:
+ conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ conditions.append("opened_at IS NULL")
+
+ # Ignore whitespace in the query
stripped_query = query.strip() if query else None
if stripped_query:
+ # Construct a wildcard query for the name, description, and tags
wildcard_query = "%" + stripped_query + "%"
- main_query += " AND name LIKE ? OR description LIKE ? "
- count_query += " AND name LIKE ? OR description LIKE ?;"
- main_params.extend([wildcard_query, wildcard_query])
- count_params.extend([wildcard_query, wildcard_query])
-
- main_query += f" ORDER BY {order_by.value} {direction.value} LIMIT ? OFFSET ?;"
- main_params.extend([per_page, page * per_page])
- self._cursor.execute(main_query, main_params)
- rows = self._cursor.fetchall()
+ query_condition = "(name LIKE ? OR description LIKE ? OR tags LIKE ?)"
+
+ conditions.append(query_condition)
+ params.extend([wildcard_query, wildcard_query, wildcard_query])
+
+ if user_id is not None:
+ # Scope to the given user but always include default workflows
+ conditions.append("(user_id = ? OR category = 'default')")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
+ if conditions:
+ # If there are conditions, add a WHERE clause and then join the conditions
+ main_query += " WHERE "
+ count_query += " WHERE "
+
+ all_conditions = " AND ".join(conditions)
+ main_query += all_conditions
+ count_query += all_conditions
+
+ # After this point, the query and params differ for the main query and the count query
+ main_params = params.copy()
+ count_params = params.copy()
+
+ # Main query also gets ORDER BY and LIMIT/OFFSET
+ main_query += f" ORDER BY {order_by.value} {direction.value}"
+
+ if per_page:
+ main_query += " LIMIT ? OFFSET ?"
+ main_params.extend([per_page, page * per_page])
+
+ # Put a ring on it
+ main_query += ";"
+ count_query += ";"
+
+ cursor.execute(main_query, main_params)
+ rows = cursor.fetchall()
workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows]
- self._cursor.execute(count_query, count_params)
- total = self._cursor.fetchone()[0]
+ cursor.execute(count_query, count_params)
+ total = cursor.fetchone()[0]
+
+ if per_page:
pages = total // per_page + (total % per_page > 0)
+ else:
+ pages = 1 # If no pagination, there is only one page
- return PaginatedResults(
- items=workflows,
- page=page,
- per_page=per_page,
- pages=pages,
- total=total,
- )
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
+ return PaginatedResults(
+ items=workflows,
+ page=page,
+ per_page=per_page if per_page else total,
+ pages=pages,
+ total=total,
+ )
+
+ def counts_by_tag(
+ self,
+ tags: list[str],
+ categories: Optional[list[WorkflowCategory]] = None,
+ has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> dict[str, int]:
+ if not tags:
+ return {}
+
+ with self._db.transaction() as cursor:
+ result: dict[str, int] = {}
+ # Base conditions for categories and selected tags
+ base_conditions: list[str] = []
+ base_params: list[str | int] = []
+
+ # Add category conditions
+ if categories:
+ assert all(c in WorkflowCategory for c in categories)
+ placeholders = ", ".join("?" for _ in categories)
+ base_conditions.append(f"category IN ({placeholders})")
+ base_params.extend([category.value for category in categories])
+
+ if has_been_opened:
+ base_conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ base_conditions.append("opened_at IS NULL")
+
+ if user_id is not None:
+ # Scope to the given user but always include default workflows
+ base_conditions.append("(user_id = ? OR category = 'default')")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
+ # For each tag to count, run a separate query
+ for tag in tags:
+ # Start with the base conditions
+ conditions = base_conditions.copy()
+ params = base_params.copy()
+
+ # Add this specific tag condition
+ conditions.append("tags LIKE ?")
+ params.append(f"%{tag.strip()}%")
+
+ # Construct the full query
+ stmt = """--sql
+ SELECT COUNT(*)
+ FROM workflow_library
+ """
+
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ count = cursor.fetchone()[0]
+ result[tag] = count
+
+ return result
+
+ def counts_by_category(
+ self,
+ categories: list[WorkflowCategory],
+ has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> dict[str, int]:
+ with self._db.transaction() as cursor:
+ result: dict[str, int] = {}
+ # Base conditions for categories
+ base_conditions: list[str] = []
+ base_params: list[str | int] = []
+
+ # Add category conditions
+ if categories:
+ assert all(c in WorkflowCategory for c in categories)
+ placeholders = ", ".join("?" for _ in categories)
+ base_conditions.append(f"category IN ({placeholders})")
+ base_params.extend([category.value for category in categories])
+
+ if has_been_opened:
+ base_conditions.append("opened_at IS NOT NULL")
+ elif has_been_opened is False:
+ base_conditions.append("opened_at IS NULL")
+
+ if user_id is not None:
+ # Scope to the given user but always include default workflows
+ base_conditions.append("(user_id = ? OR category = 'default')")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
+ # For each category to count, run a separate query
+ for category in categories:
+ # Start with the base conditions
+ conditions = base_conditions.copy()
+ params = base_params.copy()
+
+ # Add this specific category condition
+ conditions.append("category = ?")
+ params.append(category.value)
+
+ # Construct the full query
+ stmt = """--sql
+ SELECT COUNT(*)
+ FROM workflow_library
+ """
+
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ count = cursor.fetchone()[0]
+ result[category.value] = count
+
+ return result
+
+ def update_opened_at(self, workflow_id: str, user_id: Optional[str] = None) -> None:
+ with self._db.transaction() as cursor:
+ if user_id is not None:
+ cursor.execute(
+ f"""--sql
+ UPDATE workflow_library
+ SET opened_at = STRFTIME('{SQL_TIME_FORMAT}', 'NOW')
+ WHERE workflow_id = ? AND user_id = ?;
+ """,
+ (workflow_id, user_id),
+ )
+ else:
+ cursor.execute(
+ f"""--sql
+ UPDATE workflow_library
+ SET opened_at = STRFTIME('{SQL_TIME_FORMAT}', 'NOW')
+ WHERE workflow_id = ?;
+ """,
+ (workflow_id,),
+ )
+
+ def get_all_tags(
+ self,
+ categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
+ ) -> list[str]:
+ with self._db.transaction() as cursor:
+ conditions: list[str] = []
+ params: list[str] = []
+
+ # Only get workflows that have tags
+ conditions.append("tags IS NOT NULL AND tags != ''")
+
+ if categories:
+ assert all(c in WorkflowCategory for c in categories)
+ placeholders = ", ".join("?" for _ in categories)
+ conditions.append(f"category IN ({placeholders})")
+ params.extend([category.value for category in categories])
+
+ if user_id is not None:
+ # Scope to the given user but always include default workflows
+ conditions.append("(user_id = ? OR category = 'default')")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
+ stmt = """--sql
+ SELECT DISTINCT tags
+ FROM workflow_library
+ """
+
+ if conditions:
+ stmt += " WHERE " + " AND ".join(conditions)
+
+ cursor.execute(stmt, params)
+ rows = cursor.fetchall()
+
+ # Parse comma-separated tags and collect unique tags
+ all_tags: set[str] = set()
+
+ for row in rows:
+ tags_value = row[0]
+ if tags_value and isinstance(tags_value, str):
+ # Tags are stored as comma-separated string
+ for tag in tags_value.split(","):
+ tag_stripped = tag.strip()
+ if tag_stripped:
+ all_tags.add(tag_stripped)
+
+ return sorted(all_tags)
def _sync_default_workflows(self) -> None:
"""Syncs default workflows to the database. Internal use only."""
@@ -197,28 +509,68 @@ def _sync_default_workflows(self) -> None:
meaningless, as they are overwritten every time the server starts.
"""
- try:
- self._lock.acquire()
- workflows: list[Workflow] = []
+ with self._db.transaction() as cursor:
+ workflows_from_file: list[Workflow] = []
+ workflows_to_update: list[Workflow] = []
+ workflows_to_add: list[Workflow] = []
workflows_dir = Path(__file__).parent / Path("default_workflows")
workflow_paths = workflows_dir.glob("*.json")
for path in workflow_paths:
bytes_ = path.read_bytes()
- workflow_without_id = WorkflowWithoutIDValidator.validate_json(bytes_)
- workflow = Workflow(**workflow_without_id.model_dump(), id=uuid_string())
- workflows.append(workflow)
- # Only default workflows may be managed by this method
- assert all(w.meta.category is WorkflowCategory.Default for w in workflows)
- self._cursor.execute(
- """--sql
- DELETE FROM workflow_library
- WHERE category = 'default';
- """
- )
- for w in workflows:
- self._cursor.execute(
+ workflow_from_file = WorkflowValidator.validate_json(bytes_)
+
+ assert workflow_from_file.id.startswith("default_"), (
+ f'Invalid default workflow ID (must start with "default_"): {workflow_from_file.id}'
+ )
+
+ assert workflow_from_file.meta.category is WorkflowCategory.Default, (
+ f"Invalid default workflow category: {workflow_from_file.meta.category}"
+ )
+
+ workflows_from_file.append(workflow_from_file)
+
+ try:
+ workflow_from_db = self.get(workflow_from_file.id).workflow
+ if workflow_from_file != workflow_from_db:
+ self._invoker.services.logger.debug(
+ f"Updating library workflow {workflow_from_file.name} ({workflow_from_file.id})"
+ )
+ workflows_to_update.append(workflow_from_file)
+ continue
+ except WorkflowNotFoundError:
+ self._invoker.services.logger.debug(
+ f"Adding missing default workflow {workflow_from_file.name} ({workflow_from_file.id})"
+ )
+ workflows_to_add.append(workflow_from_file)
+ continue
+
+ library_workflows_from_db = self.get_many(
+ order_by=WorkflowRecordOrderBy.Name,
+ direction=SQLiteDirection.Ascending,
+ categories=[WorkflowCategory.Default],
+ ).items
+
+ workflows_from_file_ids = [w.id for w in workflows_from_file]
+
+ for w in library_workflows_from_db:
+ if w.workflow_id not in workflows_from_file_ids:
+ self._invoker.services.logger.debug(
+ f"Deleting obsolete default workflow {w.name} ({w.workflow_id})"
+ )
+ # We cannot use the `delete` method here, as it only deletes non-default workflows
+ cursor.execute(
+ """--sql
+ DELETE from workflow_library
+ WHERE workflow_id = ?;
+ """,
+ (w.workflow_id,),
+ )
+
+ for w in workflows_to_add:
+ # We cannot use the `create` method here, as it only creates non-default workflows
+ cursor.execute(
"""--sql
- INSERT OR REPLACE INTO workflow_library (
+ INSERT INTO workflow_library (
workflow_id,
workflow
)
@@ -226,9 +578,14 @@ def _sync_default_workflows(self) -> None:
""",
(w.id, w.model_dump_json()),
)
- self._conn.commit()
- except Exception:
- self._conn.rollback()
- raise
- finally:
- self._lock.release()
+
+ for w in workflows_to_update:
+ # We cannot use the `update` method here, as it only updates non-default workflows
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?
+ WHERE workflow_id = ?;
+ """,
+ (w.model_dump_json(), w.id),
+ )
diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py
new file mode 100644
index 00000000000..f51d200dea1
--- /dev/null
+++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_base.py
@@ -0,0 +1,28 @@
+from abc import ABC, abstractmethod
+from pathlib import Path
+
+from PIL import Image
+
+
+class WorkflowThumbnailServiceBase(ABC):
+ """Base class for workflow thumbnail services"""
+
+ @abstractmethod
+ def get_path(self, workflow_id: str, with_hash: bool = True) -> Path:
+ """Gets the path to a workflow thumbnail"""
+ pass
+
+ @abstractmethod
+ def get_url(self, workflow_id: str, with_hash: bool = True) -> str | None:
+ """Gets the URL of a workflow thumbnail"""
+ pass
+
+ @abstractmethod
+ def save(self, workflow_id: str, image: Image.Image) -> None:
+ """Saves a workflow thumbnail"""
+ pass
+
+ @abstractmethod
+ def delete(self, workflow_id: str) -> None:
+ """Deletes a workflow thumbnail"""
+ pass
diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py
new file mode 100644
index 00000000000..8d124adec33
--- /dev/null
+++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py
@@ -0,0 +1,22 @@
+class WorkflowThumbnailFileNotFoundException(Exception):
+ """Raised when a workflow thumbnail file is not found"""
+
+ def __init__(self, message: str = "Workflow thumbnail file not found"):
+ self.message = message
+ super().__init__(self.message)
+
+
+class WorkflowThumbnailFileSaveException(Exception):
+ """Raised when a workflow thumbnail file cannot be saved"""
+
+ def __init__(self, message: str = "Workflow thumbnail file cannot be saved"):
+ self.message = message
+ super().__init__(self.message)
+
+
+class WorkflowThumbnailFileDeleteException(Exception):
+ """Raised when a workflow thumbnail file cannot be deleted"""
+
+ def __init__(self, message: str = "Workflow thumbnail file cannot be deleted"):
+ self.message = message
+ super().__init__(self.message)
diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py
new file mode 100644
index 00000000000..3fbfa7607fe
--- /dev/null
+++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py
@@ -0,0 +1,87 @@
+from pathlib import Path
+
+from PIL import Image
+from PIL.Image import Image as PILImageType
+
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.workflow_records.workflow_records_common import WorkflowCategory
+from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase
+from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import (
+ WorkflowThumbnailFileDeleteException,
+ WorkflowThumbnailFileNotFoundException,
+ WorkflowThumbnailFileSaveException,
+)
+from invokeai.app.util.misc import uuid_string
+from invokeai.app.util.thumbnails import make_thumbnail
+
+
+class WorkflowThumbnailFileStorageDisk(WorkflowThumbnailServiceBase):
+ def __init__(self, thumbnails_path: Path):
+ self._workflow_thumbnail_folder = thumbnails_path
+ self._validate_storage_folders()
+
+ def start(self, invoker: Invoker) -> None:
+ self._invoker = invoker
+
+ def get(self, workflow_id: str) -> PILImageType:
+ try:
+ path = self.get_path(workflow_id)
+
+ return Image.open(path)
+ except FileNotFoundError as e:
+ raise WorkflowThumbnailFileNotFoundException from e
+
+ def save(self, workflow_id: str, image: PILImageType) -> None:
+ try:
+ self._validate_storage_folders()
+ image_path = self._workflow_thumbnail_folder / (workflow_id + ".webp")
+ thumbnail = make_thumbnail(image, 256)
+ thumbnail.save(image_path, format="webp")
+
+ except Exception as e:
+ raise WorkflowThumbnailFileSaveException from e
+
+ def get_path(self, workflow_id: str, with_hash: bool = True) -> Path:
+ workflow = self._invoker.services.workflow_records.get(workflow_id).workflow
+ if workflow.meta.category is WorkflowCategory.Default:
+ default_thumbnails_dir = Path(__file__).parent / Path("default_workflow_thumbnails")
+ path = default_thumbnails_dir / (workflow_id + ".png")
+ else:
+ path = self._workflow_thumbnail_folder / (workflow_id + ".webp")
+
+ return path
+
+ def get_url(self, workflow_id: str, with_hash: bool = True) -> str | None:
+ path = self.get_path(workflow_id)
+ if not self._validate_path(path):
+ return
+
+ url = self._invoker.services.urls.get_workflow_thumbnail_url(workflow_id)
+
+ # The image URL never changes, so we must add random query string to it to prevent caching
+ if with_hash:
+ url += f"?{uuid_string()}"
+
+ return url
+
+ def delete(self, workflow_id: str) -> None:
+ try:
+ path = self.get_path(workflow_id)
+
+ if not self._validate_path(path):
+ raise WorkflowThumbnailFileNotFoundException
+
+ path.unlink()
+
+ except WorkflowThumbnailFileNotFoundException as e:
+ raise WorkflowThumbnailFileNotFoundException from e
+ except Exception as e:
+ raise WorkflowThumbnailFileDeleteException from e
+
+ def _validate_path(self, path: Path) -> bool:
+ """Validates the path given for an image."""
+ return path.exists()
+
+ def _validate_storage_folders(self) -> None:
+ """Checks if the required folders exist and create them if they don't"""
+ self._workflow_thumbnail_folder.mkdir(parents=True, exist_ok=True)
diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py
index fde8d52ee64..0f14ed7bfb3 100644
--- a/invokeai/app/util/controlnet_utils.py
+++ b/invokeai/app/util/controlnet_utils.py
@@ -230,6 +230,86 @@ def heuristic_resize(np_img: np.ndarray[Any, Any], size: tuple[int, int]) -> np.
return resized
+# precompute common kernels
+_KERNEL3 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
+# directional masks for NMS
+_DIRS = [
+ np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], np.uint8),
+ np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], np.uint8),
+ np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], np.uint8),
+ np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], np.uint8),
+]
+
+
+def heuristic_resize_fast(np_img: np.ndarray, size: tuple[int, int]) -> np.ndarray:
+ h, w = np_img.shape[:2]
+ # early exit
+ if (w, h) == size:
+ return np_img
+
+ # separate alpha channel
+ img = np_img
+ alpha = None
+ if img.ndim == 3 and img.shape[2] == 4:
+ alpha, img = img[:, :, 3], img[:, :, :3]
+
+ # build small sample for unique‐color & binary detection
+ flat = img.reshape(-1, img.shape[-1])
+ N = flat.shape[0]
+ # include four corners to avoid missing extreme values
+ corners = np.vstack([img[0, 0], img[0, w - 1], img[h - 1, 0], img[h - 1, w - 1]])
+ cnt = min(N, 100_000)
+ samp = np.vstack([corners, flat[np.random.choice(N, cnt, replace=False)]])
+ uc = np.unique(samp, axis=0).shape[0]
+ vmin, vmax = samp.min(), samp.max()
+
+ # detect binary edge map & one‐pixel‐edge case
+ is_binary = uc == 2 and vmin < 16 and vmax > 240
+ one_pixel_edge = False
+ if is_binary:
+ # single gray conversion
+ gray0 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+ grad = cv2.morphologyEx(gray0, cv2.MORPH_GRADIENT, _KERNEL3)
+ cnt_edge = cv2.countNonZero(grad)
+ cnt_all = cv2.countNonZero((gray0 > 127).astype(np.uint8))
+ one_pixel_edge = (2 * cnt_edge) > cnt_all
+
+ # choose interp for color/seg/grayscale
+ area_new, area_old = size[0] * size[1], w * h
+ if 2 < uc < 200: # segmentation map
+ interp = cv2.INTER_NEAREST
+ elif area_new < area_old:
+ interp = cv2.INTER_AREA
+ else:
+ interp = cv2.INTER_CUBIC
+
+ # single resize pass on RGB
+ resized = cv2.resize(img, size, interpolation=interp)
+
+ if is_binary:
+ # convert to gray & apply NMS via C++ dilate
+ gray_r = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
+ nms = np.zeros_like(gray_r)
+ for K in _DIRS:
+ d = cv2.dilate(gray_r, K)
+ mask = d == gray_r
+ nms[mask] = gray_r[mask]
+
+ # threshold + thinning if needed
+ _, bw = cv2.threshold(nms, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
+ out_bin = cv2.ximgproc.thinning(bw) if one_pixel_edge else bw
+ # restore 3 channels
+ resized = np.stack([out_bin] * 3, axis=2)
+
+ # restore alpha with same interp as RGB for consistency
+ if alpha is not None:
+ am = cv2.resize(alpha, size, interpolation=interp)
+ am = (am > 127).astype(np.uint8) * 255
+ resized = np.dstack((resized, am))
+
+ return resized
+
+
###########################################################################
# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet
# modified for InvokeAI
@@ -244,7 +324,7 @@ def np_img_resize(
np_img = normalize_image_channel_count(np_img)
if resize_mode == "just_resize": # RESIZE
- np_img = heuristic_resize(np_img, (w, h))
+ np_img = heuristic_resize_fast(np_img, (w, h))
np_img = clone_contiguous(np_img)
return np_img_to_torch(np_img, device), np_img
@@ -265,7 +345,7 @@ def safeint(x: Union[int, float]) -> int:
# Inpaint hijack
high_quality_border_color[3] = 255
high_quality_background = np.tile(high_quality_border_color[None, None], [h, w, 1])
- np_img = heuristic_resize(np_img, (safeint(old_w * k), safeint(old_h * k)))
+ np_img = heuristic_resize_fast(np_img, (safeint(old_w * k), safeint(old_h * k)))
new_h, new_w, _ = np_img.shape
pad_h = max(0, (h - new_h) // 2)
pad_w = max(0, (w - new_w) // 2)
@@ -275,7 +355,7 @@ def safeint(x: Union[int, float]) -> int:
return np_img_to_torch(np_img, device), np_img
else: # resize_mode == "crop_resize" (INNER_FIT)
k = max(k0, k1)
- np_img = heuristic_resize(np_img, (safeint(old_w * k), safeint(old_h * k)))
+ np_img = heuristic_resize_fast(np_img, (safeint(old_w * k), safeint(old_h * k)))
new_h, new_w, _ = np_img.shape
pad_h = max(0, (new_h - h) // 2)
pad_w = max(0, (new_w - w) // 2)
@@ -289,7 +369,7 @@ def prepare_control_image(
width: int,
height: int,
num_channels: int = 3,
- device: str = "cuda",
+ device: str | torch.device = "cuda",
dtype: torch.dtype = torch.float16,
control_mode: CONTROLNET_MODE_VALUES = "balanced",
resize_mode: CONTROLNET_RESIZE_VALUES = "just_resize_simple",
@@ -304,7 +384,7 @@ def prepare_control_image(
num_channels (int, optional): The target number of image channels. This is achieved by converting the input
image to RGB, then naively taking the first `num_channels` channels. The primary use case is converting a
RGB image to a single-channel grayscale image. Raises if `num_channels` cannot be achieved. Defaults to 3.
- device (str, optional): The target device for the output image. Defaults to "cuda".
+ device (str | torch.Device, optional): The target device for the output image. Defaults to "cuda".
dtype (_type_, optional): The dtype for the output image. Defaults to torch.float16.
do_classifier_free_guidance (bool, optional): If True, repeat the output image along the batch dimension.
Defaults to True.
diff --git a/invokeai/app/util/custom_openapi.py b/invokeai/app/util/custom_openapi.py
index 50259c12ccc..f674fa76218 100644
--- a/invokeai/app/util/custom_openapi.py
+++ b/invokeai/app/util/custom_openapi.py
@@ -4,11 +4,18 @@
from fastapi.openapi.utils import get_openapi
from pydantic.json_schema import models_json_schema
-from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, UIConfigBase
+from invokeai.app.invocations.baseinvocation import (
+ InvocationRegistry,
+ UIConfigBase,
+)
from invokeai.app.invocations.fields import InputFieldJSONSchemaExtra, OutputFieldJSONSchemaExtra
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.events.events_common import EventBase
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
+from invokeai.backend.model_manager.configs.factory import AnyModelConfigValidator
+from invokeai.backend.util.logging import InvokeAILogger
+
+logger = InvokeAILogger.get_logger()
def move_defs_to_top_level(openapi_schema: dict[str, Any], component_schema: dict[str, Any]) -> None:
@@ -23,6 +30,23 @@ def move_defs_to_top_level(openapi_schema: dict[str, Any], component_schema: dic
openapi_schema["components"]["schemas"][schema_key] = json_schema
+def normalize_path_defaults(node: Any) -> None:
+ """Recursively normalize `default` strings on schema nodes whose `format` is `path` to use forward slashes.
+
+ Pydantic stringifies `Path` defaults using the host OS's separator, so a default declared as
+ `Path("models/.convert_cache")` serializes to `models\\.convert_cache` on Windows. That OS-dependent drift
+ pollutes diffs whenever schema is regenerated on Windows. We force POSIX form for path-typed defaults.
+ """
+ if isinstance(node, dict):
+ if node.get("format") == "path" and isinstance(node.get("default"), str):
+ node["default"] = node["default"].replace("\\", "/")
+ for v in node.values():
+ normalize_path_defaults(v)
+ elif isinstance(node, list):
+ for v in node:
+ normalize_path_defaults(v)
+
+
def get_openapi_func(
app: FastAPI, post_transform: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None
) -> Callable[[], dict[str, Any]]:
@@ -56,14 +80,18 @@ def openapi() -> dict[str, Any]:
invocation_output_map_required: list[str] = []
# We need to manually add all outputs to the schema - pydantic doesn't add them because they aren't used directly.
- for output in BaseInvocationOutput.get_outputs():
+ for output in InvocationRegistry.get_output_classes():
json_schema = output.model_json_schema(mode="serialization", ref_template="#/components/schemas/{model}")
+ # Remove output_metadata that is only used on back-end from the schema
+ if "output_meta" in json_schema["properties"]:
+ json_schema["properties"].pop("output_meta")
+
move_defs_to_top_level(openapi_schema, json_schema)
openapi_schema["components"]["schemas"][output.__name__] = json_schema
# Technically, invocations are added to the schema by pydantic, but we still need to manually set their output
# property, so we'll just do it all manually.
- for invocation in BaseInvocation.get_invocations():
+ for invocation in InvocationRegistry.get_invocation_classes():
json_schema = invocation.model_json_schema(
mode="serialization", ref_template="#/components/schemas/{model}"
)
@@ -81,8 +109,8 @@ def openapi() -> dict[str, Any]:
# Add the output map to the schema
openapi_schema["components"]["schemas"]["InvocationOutputMap"] = {
"type": "object",
- "properties": invocation_output_map_properties,
- "required": invocation_output_map_required,
+ "properties": dict(sorted(invocation_output_map_properties.items())),
+ "required": sorted(invocation_output_map_required),
}
# Some models don't end up in the schemas as standalone definitions because they aren't used directly in the API.
@@ -105,9 +133,18 @@ def openapi() -> dict[str, Any]:
# additional_schemas[1] is a dict of $defs that we need to add to the top level of the schema
move_defs_to_top_level(openapi_schema, additional_schemas[1])
+ any_model_config_schema = AnyModelConfigValidator.json_schema(
+ mode="serialization",
+ ref_template="#/components/schemas/{model}",
+ )
+ move_defs_to_top_level(openapi_schema, any_model_config_schema)
+ openapi_schema["components"]["schemas"]["AnyModelConfig"] = any_model_config_schema
+
if post_transform is not None:
openapi_schema = post_transform(openapi_schema)
+ normalize_path_defaults(openapi_schema)
+
openapi_schema["components"]["schemas"] = dict(sorted(openapi_schema["components"]["schemas"].items()))
app.openapi_schema = openapi_schema
diff --git a/invokeai/app/util/misc.py b/invokeai/app/util/misc.py
index da431929dbe..f75683539ac 100644
--- a/invokeai/app/util/misc.py
+++ b/invokeai/app/util/misc.py
@@ -10,7 +10,7 @@ def get_timestamp() -> int:
def get_iso_timestamp() -> str:
- return datetime.datetime.utcnow().isoformat()
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
def get_datetime_from_iso_timestamp(iso_timestamp: str) -> datetime.datetime:
diff --git a/invokeai/app/util/startup_utils.py b/invokeai/app/util/startup_utils.py
new file mode 100644
index 00000000000..08368021cff
--- /dev/null
+++ b/invokeai/app/util/startup_utils.py
@@ -0,0 +1,74 @@
+import logging
+import mimetypes
+import socket
+from pathlib import Path
+
+import torch
+
+
+def find_open_port(port: int) -> int:
+ """Find a port not in use starting at given port"""
+ # Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
+ # https://github.com/WaylonWalker
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(1)
+ if s.connect_ex(("localhost", port)) == 0:
+ return find_open_port(port=port + 1)
+ else:
+ return port
+
+
+def check_cudnn(logger: logging.Logger) -> None:
+ """Check for cuDNN issues that could be causing degraded performance."""
+ if torch.backends.cudnn.is_available():
+ try:
+ # Note: At the time of writing (torch 2.2.1), torch.backends.cudnn.version() only raises an error the first
+ # time it is called. Subsequent calls will return the version number without complaining about a mismatch.
+ cudnn_version = torch.backends.cudnn.version()
+ logger.info(f"cuDNN version: {cudnn_version}")
+ except RuntimeError as e:
+ logger.warning(
+ "Encountered a cuDNN version issue. This may result in degraded performance. This issue is usually "
+ "caused by an incompatible cuDNN version installed in your python environment, or on the host "
+ f"system. Full error message:\n{e}"
+ )
+
+
+def invokeai_source_dir() -> Path:
+ # `invokeai.__file__` doesn't always work for editable installs
+ this_module_path = Path(__file__).resolve()
+ # https://youtrack.jetbrains.com/issue/PY-38382/Unresolved-reference-spec-but-this-is-standard-builtin
+ # noinspection PyUnresolvedReferences
+ depth = len(__spec__.parent.split("."))
+ return this_module_path.parents[depth - 1]
+
+
+def enable_dev_reload(custom_nodes_path=None) -> None:
+ """Enable hot reloading on python file changes during development."""
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ try:
+ import jurigged
+ except ImportError as e:
+ raise RuntimeError(
+ 'Can\'t start `--dev_reload` because jurigged is not found; `pip install -e ".[dev]"` to include development dependencies.'
+ ) from e
+ else:
+ paths = [str(invokeai_source_dir() / "*.py")]
+ if custom_nodes_path:
+ paths.append(str(custom_nodes_path / "*.py"))
+ jurigged.watch(pattern=paths, logger=InvokeAILogger.get_logger(name="jurigged").info)
+
+
+def apply_monkeypatches() -> None:
+ """Apply monkeypatches to fix issues with third-party libraries."""
+
+ import invokeai.backend.util.hotfixes # noqa: F401 (monkeypatching on import)
+
+
+def register_mime_types() -> None:
+ """Register additional mime types for windows."""
+ # Fix for windows mimetypes registry entries being borked.
+ # see https://github.com/invoke-ai/InvokeAI/discussions/3684#discussioncomment-6391352
+ mimetypes.add_type("application/javascript", ".js")
+ mimetypes.add_type("text/css", ".css")
diff --git a/invokeai/app/util/step_callback.py b/invokeai/app/util/step_callback.py
index 8992e59ace9..08dc9a2265c 100644
--- a/invokeai/app/util/step_callback.py
+++ b/invokeai/app/util/step_callback.py
@@ -1,17 +1,14 @@
-from typing import TYPE_CHECKING, Callable, Optional
+from math import floor
+from typing import Callable, Optional, TypeAlias
import torch
from PIL import Image
-from invokeai.app.services.session_processor.session_processor_common import CanceledException, ProgressImage
-from invokeai.backend.model_manager.config import BaseModelType
+from invokeai.app.services.session_processor.session_processor_common import CanceledException
+from invokeai.backend.model_manager.taxonomy import BaseModelType
+from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
-from ...backend.stable_diffusion import PipelineIntermediateState
-from ...backend.util.util import image_to_dataURL
-
-if TYPE_CHECKING:
- from invokeai.app.services.events.events_base import EventServiceBase
- from invokeai.app.services.shared.invocation_context import InvocationContextData
+# See scripts/generate_vae_linear_approximation.py for generating these factors.
# fast latents preview matrix for sdxl
# generated by @StAlKeR7779
@@ -39,11 +36,162 @@
[-0.1307, -0.1874, -0.7445], # L4
]
+SD3_5_LATENT_RGB_FACTORS = [
+ [-0.05240681, 0.03251581, 0.0749016],
+ [-0.0580572, 0.00759826, 0.05729818],
+ [0.16144888, 0.01270368, -0.03768577],
+ [0.14418615, 0.08460266, 0.15941818],
+ [0.04894035, 0.0056485, -0.06686988],
+ [0.05187166, 0.19222395, 0.06261094],
+ [0.1539433, 0.04818359, 0.07103094],
+ [-0.08601796, 0.09013458, 0.10893912],
+ [-0.12398469, -0.06766567, 0.0033688],
+ [-0.0439737, 0.07825329, 0.02258823],
+ [0.03101129, 0.06382551, 0.07753657],
+ [-0.01315361, 0.08554491, -0.08772475],
+ [0.06464487, 0.05914605, 0.13262741],
+ [-0.07863674, -0.02261737, -0.12761454],
+ [-0.09923835, -0.08010759, -0.06264447],
+ [-0.03392309, -0.0804029, -0.06078822],
+]
+
+FLUX_LATENT_RGB_FACTORS = [
+ [-0.0412, 0.0149, 0.0521],
+ [0.0056, 0.0291, 0.0768],
+ [0.0342, -0.0681, -0.0427],
+ [-0.0258, 0.0092, 0.0463],
+ [0.0863, 0.0784, 0.0547],
+ [-0.0017, 0.0402, 0.0158],
+ [0.0501, 0.1058, 0.1152],
+ [-0.0209, -0.0218, -0.0329],
+ [-0.0314, 0.0083, 0.0896],
+ [0.0851, 0.0665, -0.0472],
+ [-0.0534, 0.0238, -0.0024],
+ [0.0452, -0.0026, 0.0048],
+ [0.0892, 0.0831, 0.0881],
+ [-0.1117, -0.0304, -0.0789],
+ [0.0027, -0.0479, -0.0043],
+ [-0.1146, -0.0827, -0.0598],
+]
+
+COGVIEW4_LATENT_RGB_FACTORS = [
+ [0.00408832, -0.00082485, -0.00214816],
+ [0.00084172, 0.00132241, 0.00842067],
+ [-0.00466737, -0.00983181, -0.00699561],
+ [0.03698397, -0.04797235, 0.03585809],
+ [0.00234701, -0.00124326, 0.00080869],
+ [-0.00723903, -0.00388422, -0.00656606],
+ [-0.00970917, -0.00467356, -0.00971113],
+ [0.17292486, -0.03452463, -0.1457515],
+ [0.02330308, 0.02942557, 0.02704329],
+ [-0.00903131, -0.01499841, -0.01432564],
+ [0.01250298, 0.0019407, -0.02168986],
+ [0.01371188, 0.00498283, -0.01302135],
+ [0.42396525, 0.4280575, 0.42148206],
+ [0.00983825, 0.00613302, 0.00610316],
+ [0.00473307, -0.00889551, -0.00915924],
+ [-0.00955853, -0.00980067, -0.00977842],
+]
+
+# Qwen Image uses the same VAE as Wan 2.1 (16-channel).
+# Factors from ComfyUI: https://github.com/comfyanonymous/ComfyUI/blob/master/comfy/latent_formats.py
+QWEN_IMAGE_LATENT_RGB_FACTORS = [
+ [-0.1299, -0.1692, 0.2932],
+ [0.0671, 0.0406, 0.0442],
+ [0.3568, 0.2548, 0.1747],
+ [0.0372, 0.2344, 0.1420],
+ [0.0313, 0.0189, -0.0328],
+ [0.0296, -0.0956, -0.0665],
+ [-0.3477, -0.4059, -0.2925],
+ [0.0166, 0.1902, 0.1975],
+ [-0.0412, 0.0267, -0.1364],
+ [-0.1293, 0.0740, 0.1636],
+ [0.0680, 0.3019, 0.1128],
+ [0.0032, 0.0581, 0.0639],
+ [-0.1251, 0.0927, 0.1699],
+ [0.0060, -0.0633, 0.0005],
+ [0.3477, 0.2275, 0.2950],
+ [0.1984, 0.0913, 0.1861],
+]
+
+QWEN_IMAGE_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
+
+# FLUX.2 uses 32 latent channels.
+# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py
+FLUX2_LATENT_RGB_FACTORS = [
+ # R G B
+ [0.0058, 0.0113, 0.0073],
+ [0.0495, 0.0443, 0.0836],
+ [-0.0099, 0.0096, 0.0644],
+ [0.2144, 0.3009, 0.3652],
+ [0.0166, -0.0039, -0.0054],
+ [0.0157, 0.0103, -0.0160],
+ [-0.0398, 0.0902, -0.0235],
+ [-0.0052, 0.0095, 0.0109],
+ [-0.3527, -0.2712, -0.1666],
+ [-0.0301, -0.0356, -0.0180],
+ [-0.0107, 0.0078, 0.0013],
+ [0.0746, 0.0090, -0.0941],
+ [0.0156, 0.0169, 0.0070],
+ [-0.0034, -0.0040, -0.0114],
+ [0.0032, 0.0181, 0.0080],
+ [-0.0939, -0.0008, 0.0186],
+ [0.0018, 0.0043, 0.0104],
+ [0.0284, 0.0056, -0.0127],
+ [-0.0024, -0.0022, -0.0030],
+ [0.1207, -0.0026, 0.0065],
+ [0.0128, 0.0101, 0.0142],
+ [0.0137, -0.0072, -0.0007],
+ [0.0095, 0.0092, -0.0059],
+ [0.0000, -0.0077, -0.0049],
+ [-0.0465, -0.0204, -0.0312],
+ [0.0095, 0.0012, -0.0066],
+ [0.0290, -0.0034, 0.0025],
+ [0.0220, 0.0169, -0.0048],
+ [-0.0332, -0.0457, -0.0468],
+ [-0.0085, 0.0389, 0.0609],
+ [-0.0076, 0.0003, -0.0043],
+ [-0.0111, -0.0460, -0.0614],
+]
+
+FLUX2_LATENT_RGB_BIAS = [-0.0329, -0.0718, -0.0851]
+
+# Anima uses Wan 2.1 VAE with 16 latent channels.
+# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py
+ANIMA_LATENT_RGB_FACTORS = [
+ [-0.1299, -0.1692, 0.2932],
+ [0.0671, 0.0406, 0.0442],
+ [0.3568, 0.2548, 0.1747],
+ [0.0372, 0.2344, 0.1420],
+ [0.0313, 0.0189, -0.0328],
+ [0.0296, -0.0956, -0.0665],
+ [-0.3477, -0.4059, -0.2925],
+ [0.0166, 0.1902, 0.1975],
+ [-0.0412, 0.0267, -0.1364],
+ [-0.1293, 0.0740, 0.1636],
+ [0.0680, 0.3019, 0.1128],
+ [0.0032, 0.0581, 0.0639],
+ [-0.1251, 0.0927, 0.1699],
+ [0.0060, -0.0633, 0.0005],
+ [0.3477, 0.2275, 0.2950],
+ [0.1984, 0.0913, 0.1861],
+]
+
+ANIMA_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
+
def sample_to_lowres_estimated_image(
- samples: torch.Tensor, latent_rgb_factors: torch.Tensor, smooth_matrix: Optional[torch.Tensor] = None
+ samples: torch.Tensor,
+ latent_rgb_factors: torch.Tensor,
+ smooth_matrix: Optional[torch.Tensor] = None,
+ latent_rgb_bias: Optional[torch.Tensor] = None,
):
- latent_image = samples[0].permute(1, 2, 0) @ latent_rgb_factors
+ if samples.dim() == 4:
+ samples = samples[0]
+ latent_image = samples.permute(1, 2, 0) @ latent_rgb_factors
+
+ if latent_rgb_bias is not None:
+ latent_image = latent_image + latent_rgb_bias
if smooth_matrix is not None:
latent_image = latent_image.unsqueeze(0).permute(3, 0, 1, 2)
@@ -57,11 +205,32 @@ def sample_to_lowres_estimated_image(
return Image.fromarray(latents_ubyte.numpy())
-def stable_diffusion_step_callback(
- context_data: "InvocationContextData",
+def calc_percentage(intermediate_state: PipelineIntermediateState) -> float:
+ """Calculate the percentage of completion of denoising."""
+
+ step = intermediate_state.step
+ total_steps = intermediate_state.total_steps
+ order = intermediate_state.order
+
+ if total_steps == 0:
+ return 0.0
+ if order == 2:
+ # Prevent division by zero when total_steps is 1 or 2
+ denominator = floor(total_steps / 2)
+ if denominator == 0:
+ return 0.0
+ return floor(step / 2) / denominator
+ # order == 1
+ return step / total_steps
+
+
+SignalProgressFunc: TypeAlias = Callable[[str, float | None, Image.Image | None, tuple[int, int] | None], None]
+
+
+def diffusion_step_callback(
+ signal_progress: SignalProgressFunc,
intermediate_state: PipelineIntermediateState,
base_model: BaseModelType,
- events: "EventServiceBase",
is_canceled: Callable[[], bool],
) -> None:
if is_canceled():
@@ -75,23 +244,51 @@ def stable_diffusion_step_callback(
else:
sample = intermediate_state.latents
- if base_model in [BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXLRefiner]:
- sdxl_latent_rgb_factors = torch.tensor(SDXL_LATENT_RGB_FACTORS, dtype=sample.dtype, device=sample.device)
- sdxl_smooth_matrix = torch.tensor(SDXL_SMOOTH_MATRIX, dtype=sample.dtype, device=sample.device)
- image = sample_to_lowres_estimated_image(sample, sdxl_latent_rgb_factors, sdxl_smooth_matrix)
+ smooth_matrix: list[list[float]] | None = None
+ latent_rgb_bias: list[float] | None = None
+ if base_model in [BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]:
+ latent_rgb_factors = SD1_5_LATENT_RGB_FACTORS
+ elif base_model in [BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXLRefiner]:
+ latent_rgb_factors = SDXL_LATENT_RGB_FACTORS
+ smooth_matrix = SDXL_SMOOTH_MATRIX
+ elif base_model == BaseModelType.StableDiffusion3:
+ latent_rgb_factors = SD3_5_LATENT_RGB_FACTORS
+ elif base_model == BaseModelType.CogView4:
+ latent_rgb_factors = COGVIEW4_LATENT_RGB_FACTORS
+ elif base_model == BaseModelType.QwenImage:
+ latent_rgb_factors = QWEN_IMAGE_LATENT_RGB_FACTORS
+ latent_rgb_bias = QWEN_IMAGE_LATENT_RGB_BIAS
+ elif base_model == BaseModelType.Flux:
+ latent_rgb_factors = FLUX_LATENT_RGB_FACTORS
+ elif base_model == BaseModelType.Flux2:
+ latent_rgb_factors = FLUX2_LATENT_RGB_FACTORS
+ latent_rgb_bias = FLUX2_LATENT_RGB_BIAS
+ elif base_model == BaseModelType.ZImage:
+ # Z-Image uses FLUX-compatible VAE with 16 latent channels
+ latent_rgb_factors = FLUX_LATENT_RGB_FACTORS
+ elif base_model == BaseModelType.Anima:
+ # Anima uses Wan 2.1 VAE with 16 latent channels
+ latent_rgb_factors = ANIMA_LATENT_RGB_FACTORS
+ latent_rgb_bias = ANIMA_LATENT_RGB_BIAS
else:
- v1_5_latent_rgb_factors = torch.tensor(SD1_5_LATENT_RGB_FACTORS, dtype=sample.dtype, device=sample.device)
- image = sample_to_lowres_estimated_image(sample, v1_5_latent_rgb_factors)
+ raise ValueError(f"Unsupported base model: {base_model}")
- (width, height) = image.size
- width *= 8
- height *= 8
+ latent_rgb_factors_torch = torch.tensor(latent_rgb_factors, dtype=sample.dtype, device=sample.device)
+ smooth_matrix_torch = (
+ torch.tensor(smooth_matrix, dtype=sample.dtype, device=sample.device) if smooth_matrix else None
+ )
+ latent_rgb_bias_torch = (
+ torch.tensor(latent_rgb_bias, dtype=sample.dtype, device=sample.device) if latent_rgb_bias else None
+ )
+ image = sample_to_lowres_estimated_image(
+ samples=sample,
+ latent_rgb_factors=latent_rgb_factors_torch,
+ smooth_matrix=smooth_matrix_torch,
+ latent_rgb_bias=latent_rgb_bias_torch,
+ )
- dataURL = image_to_dataURL(image, image_format="JPEG")
+ width = image.width * 8
+ height = image.height * 8
+ percentage = calc_percentage(intermediate_state)
- events.emit_invocation_denoise_progress(
- context_data.queue_item,
- context_data.invocation,
- intermediate_state,
- ProgressImage(dataURL=dataURL, width=width, height=height),
- )
+ signal_progress("Denoising", percentage, image, (width, height))
diff --git a/invokeai/app/util/t5_model_identifier.py b/invokeai/app/util/t5_model_identifier.py
new file mode 100644
index 00000000000..a0d999920c8
--- /dev/null
+++ b/invokeai/app/util/t5_model_identifier.py
@@ -0,0 +1,26 @@
+from invokeai.app.invocations.model import ModelIdentifierField
+from invokeai.backend.model_manager.taxonomy import BaseModelType, SubModelType
+
+
+def preprocess_t5_encoder_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField:
+ """A helper function to normalize a T5 encoder model identifier so that T5 models associated with FLUX
+ or SD3 models can be used interchangeably.
+ """
+ if model_identifier.base == BaseModelType.Any:
+ return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
+ elif model_identifier.base == BaseModelType.StableDiffusion3:
+ return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder3})
+ else:
+ raise ValueError(f"Unsupported model base: {model_identifier.base}")
+
+
+def preprocess_t5_tokenizer_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField:
+ """A helper function to normalize a T5 tokenizer model identifier so that T5 models associated with FLUX
+ or SD3 models can be used interchangeably.
+ """
+ if model_identifier.base == BaseModelType.Any:
+ return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer2})
+ elif model_identifier.base == BaseModelType.StableDiffusion3:
+ return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer3})
+ else:
+ raise ValueError(f"Unsupported model base: {model_identifier.base}")
diff --git a/invokeai/app/util/ti_utils.py b/invokeai/app/util/ti_utils.py
index 34669fe64ef..8f18b14d66b 100644
--- a/invokeai/app/util/ti_utils.py
+++ b/invokeai/app/util/ti_utils.py
@@ -4,7 +4,7 @@
import invokeai.backend.util.logging as logger
from invokeai.app.services.model_records import UnknownModelException
from invokeai.app.services.shared.invocation_context import InvocationContext
-from invokeai.backend.model_manager.config import BaseModelType, ModelType
+from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
from invokeai.backend.textual_inversion import TextualInversionModelRaw
diff --git a/invokeai/app/util/torch_cuda_allocator.py b/invokeai/app/util/torch_cuda_allocator.py
new file mode 100644
index 00000000000..d1c34cd3ceb
--- /dev/null
+++ b/invokeai/app/util/torch_cuda_allocator.py
@@ -0,0 +1,52 @@
+import logging
+import os
+import sys
+
+
+def configure_torch_cuda_allocator(pytorch_cuda_alloc_conf: str, logger: logging.Logger):
+ """Configure the PyTorch CUDA memory allocator. See
+ https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf for supported
+ configurations.
+ """
+
+ if "torch" in sys.modules:
+ raise RuntimeError("configure_torch_cuda_allocator() must be called before importing torch.")
+
+ # Log a warning if the PYTORCH_CUDA_ALLOC_CONF environment variable is already set.
+ prev_cuda_alloc_conf = os.environ.get("PYTORCH_CUDA_ALLOC_CONF", None)
+ if prev_cuda_alloc_conf is not None:
+ if prev_cuda_alloc_conf == pytorch_cuda_alloc_conf:
+ logger.info(
+ f"PYTORCH_CUDA_ALLOC_CONF is already set to '{pytorch_cuda_alloc_conf}'. Skipping configuration."
+ )
+ return
+ else:
+ logger.warning(
+ f"Attempted to configure the PyTorch CUDA memory allocator with '{pytorch_cuda_alloc_conf}', but PYTORCH_CUDA_ALLOC_CONF is already set to "
+ f"'{prev_cuda_alloc_conf}'. Skipping configuration."
+ )
+ return
+
+ # Configure the PyTorch CUDA memory allocator.
+ # NOTE: It is important that this happens before torch is imported.
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = pytorch_cuda_alloc_conf
+
+ import torch
+
+ # Relevant docs: https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf
+ if not torch.cuda.is_available():
+ raise RuntimeError(
+ "Attempted to configure the PyTorch CUDA memory allocator, but no CUDA devices are available."
+ )
+
+ # Verify that the torch allocator was properly configured.
+ allocator_backend = torch.cuda.get_allocator_backend()
+ expected_backend = "cudaMallocAsync" if "cudaMallocAsync" in pytorch_cuda_alloc_conf else "native"
+ if allocator_backend != expected_backend:
+ raise RuntimeError(
+ f"Failed to configure the PyTorch CUDA memory allocator. Expected backend: '{expected_backend}', but got "
+ f"'{allocator_backend}'. Verify that 1) the pytorch_cuda_alloc_conf is set correctly, and 2) that torch is "
+ "not imported before calling configure_torch_cuda_allocator()."
+ )
+
+ logger.info(f"PyTorch CUDA memory allocator: {torch.cuda.get_allocator_backend()}")
diff --git a/invokeai/app/util/user_management.py b/invokeai/app/util/user_management.py
new file mode 100644
index 00000000000..24b1fe91ab9
--- /dev/null
+++ b/invokeai/app/util/user_management.py
@@ -0,0 +1,579 @@
+"""User management command entry points for InvokeAI.
+
+These functions are registered as console scripts in pyproject.toml and can be
+called from the command line after installing the package:
+
+ invoke-useradd -- add a user
+ invoke-userdel -- delete a user
+ invoke-userlist -- list users
+ invoke-usermod -- modify a user
+"""
+
+import argparse
+import getpass
+import json
+import os
+import sys
+
+_root_help = (
+ "Path to the InvokeAI root directory. If omitted, the root is resolved in this order: "
+ "the $INVOKEAI_ROOT environment variable, the active virtual environment's parent directory, "
+ "or $HOME/invokeai."
+)
+
+# ---------------------------------------------------------------------------
+# useradd
+# ---------------------------------------------------------------------------
+
+
+def _add_user_interactive() -> bool:
+ """Add a user interactively by prompting for details."""
+ from invokeai.app.services.auth.password_utils import validate_password_strength
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_common import UserCreateRequest
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ print("=== Add InvokeAI User ===\n")
+
+ email = input("Email address: ").strip()
+ if not email:
+ print("Error: Email is required")
+ return False
+
+ display_name = input("Display name (optional): ").strip() or None
+
+ while True:
+ password = getpass.getpass("Password: ")
+ password_confirm = getpass.getpass("Confirm password: ")
+
+ if password != password_confirm:
+ print("Error: Passwords do not match. Please try again.\n")
+ continue
+
+ is_valid, error_msg = validate_password_strength(password)
+ if not is_valid:
+ print(f"Error: {error_msg}\n")
+ continue
+
+ break
+
+ is_admin_input = input("Make this user an administrator? (y/N): ").strip().lower()
+ is_admin = is_admin_input in ("y", "yes")
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin)
+ user = user_service.create(user_data)
+
+ print("\n✅ User created successfully!")
+ print(f" User ID: {user.user_id}")
+ print(f" Email: {user.email}")
+ print(f" Display Name: {user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if user.is_active else 'No'}")
+ return True
+
+ except ValueError as e:
+ print(f"\n❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"\n❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def _add_user_cli(email: str, password: str, display_name: str | None = None, is_admin: bool = False) -> bool:
+ """Add a user via CLI arguments."""
+ from invokeai.app.services.auth.password_utils import validate_password_strength
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_common import UserCreateRequest
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ is_valid, error_msg = validate_password_strength(password)
+ if not is_valid:
+ print(f"❌ Password validation failed: {error_msg}")
+ return False
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin)
+ user = user_service.create(user_data)
+
+ print("✅ User created successfully!")
+ print(f" User ID: {user.user_id}")
+ print(f" Email: {user.email}")
+ print(f" Display Name: {user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if user.is_active else 'No'}")
+ return True
+
+ except ValueError as e:
+ print(f"❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def useradd() -> None:
+ """Entry point for ``invoke-useradd``."""
+ parser = argparse.ArgumentParser(
+ description="Add a user to the InvokeAI database",
+ epilog="If no arguments are provided, the script will run in interactive mode.",
+ )
+ parser.add_argument("--root", "-r", help=_root_help)
+ parser.add_argument("--email", "-e", help="User email address")
+ parser.add_argument("--password", "-p", help="User password")
+ parser.add_argument("--name", "-n", help="User display name (optional)")
+ parser.add_argument("--admin", "-a", action="store_true", help="Make user an administrator")
+
+ args = parser.parse_args()
+
+ if args.root:
+ os.environ["INVOKEAI_ROOT"] = args.root
+
+ if args.email or args.password:
+ if not args.email or not args.password:
+ print("❌ Error: Both --email and --password are required when using CLI mode")
+ print(" Run without arguments for interactive mode")
+ sys.exit(1)
+ success = _add_user_cli(args.email, args.password, args.name, args.admin)
+ else:
+ success = _add_user_interactive()
+
+ sys.exit(0 if success else 1)
+
+
+# ---------------------------------------------------------------------------
+# userdel
+# ---------------------------------------------------------------------------
+
+
+def _delete_user_interactive() -> bool:
+ """Delete a user interactively by prompting for email."""
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ print("=== Delete InvokeAI User ===\n")
+
+ email = input("Email address of user to delete: ").strip()
+ if not email:
+ print("Error: Email is required")
+ return False
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user = user_service.get_by_email(email)
+ if not user:
+ print(f"\n❌ Error: No user found with email '{email}'")
+ return False
+
+ print("\nUser to delete:")
+ print(f" User ID: {user.user_id}")
+ print(f" Email: {user.email}")
+ print(f" Display Name: {user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if user.is_active else 'No'}")
+
+ confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower()
+ if confirm not in ("yes", "y"):
+ print("Deletion cancelled.")
+ return False
+
+ user_service.delete(user.user_id)
+ print("\n✅ User deleted successfully!")
+ return True
+
+ except ValueError as e:
+ print(f"\n❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"\n❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def _delete_user_cli(email: str, force: bool = False) -> bool:
+ """Delete a user via CLI arguments."""
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user = user_service.get_by_email(email)
+ if not user:
+ print(f"❌ Error: No user found with email '{email}'")
+ return False
+
+ if not force:
+ print("User to delete:")
+ print(f" User ID: {user.user_id}")
+ print(f" Email: {user.email}")
+ print(f" Display Name: {user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if user.is_active else 'No'}")
+
+ confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower()
+ if confirm not in ("yes", "y"):
+ print("Deletion cancelled.")
+ return False
+
+ user_service.delete(user.user_id)
+ print("✅ User deleted successfully!")
+ return True
+
+ except ValueError as e:
+ print(f"❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def userdel() -> None:
+ """Entry point for ``invoke-userdel``."""
+ parser = argparse.ArgumentParser(
+ description="Delete a user from the InvokeAI database",
+ epilog="If no arguments are provided, the script will run in interactive mode.",
+ )
+ parser.add_argument("--root", "-r", help=_root_help)
+ parser.add_argument("--email", "-e", help="User email address")
+ parser.add_argument("--force", "-f", action="store_true", help="Delete without confirmation prompt")
+
+ args = parser.parse_args()
+
+ if args.root:
+ os.environ["INVOKEAI_ROOT"] = args.root
+
+ if args.email:
+ success = _delete_user_cli(args.email, args.force)
+ else:
+ success = _delete_user_interactive()
+
+ sys.exit(0 if success else 1)
+
+
+# ---------------------------------------------------------------------------
+# userlist
+# ---------------------------------------------------------------------------
+
+
+def _list_users_table() -> bool:
+ """List all users in a formatted table."""
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ config = get_config()
+ logger = InvokeAILogger.get_logger(config=config)
+ db = SqliteDatabase(config.db_path, logger)
+ user_service = UserService(db)
+
+ try:
+ users = user_service.list_users()
+
+ if not users:
+ print("No users found in database.")
+ return True
+
+ print("\n=== InvokeAI Users ===\n")
+ print(f"{'User ID':<36} {'Email':<30} {'Display Name':<20} {'Admin':<8} {'Active':<8}")
+ print("-" * 108)
+
+ for user in users:
+ user_id = user.user_id
+ email = user.email[:29] if len(user.email) > 29 else user.email
+ raw_name = user.display_name or ""
+ name = raw_name[:19] if len(raw_name) > 19 else raw_name
+ is_admin = "Yes" if user.is_admin else "No"
+ is_active = "Yes" if user.is_active else "No"
+ print(f"{user_id:<36} {email:<30} {name:<20} {is_admin:<8} {is_active:<8}")
+
+ print(f"\nTotal users: {len(users)}")
+ return True
+
+ except Exception as e:
+ print(f"Error listing users: {e}")
+ return False
+
+
+def _list_users_json() -> bool:
+ """List all users in JSON format."""
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ config = get_config()
+ logger = InvokeAILogger.get_logger(config=config)
+ db = SqliteDatabase(config.db_path, logger)
+ user_service = UserService(db)
+
+ try:
+ users = user_service.list_users()
+
+ users_data = [
+ {
+ "id": user.user_id,
+ "email": user.email,
+ "name": user.display_name,
+ "is_admin": user.is_admin,
+ "is_active": user.is_active,
+ }
+ for user in users
+ ]
+
+ print(json.dumps(users_data, indent=2))
+ return True
+
+ except Exception as e:
+ print(f'{{"error": "{e}"}}', file=sys.stderr)
+ return False
+
+
+def userlist() -> None:
+ """Entry point for ``invoke-userlist``."""
+ parser = argparse.ArgumentParser(
+ description="List users from the InvokeAI database",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ invoke-userlist
+ invoke-userlist --json
+ """,
+ )
+ parser.add_argument("--root", "-r", help=_root_help)
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output users in JSON format instead of table",
+ )
+
+ args = parser.parse_args()
+
+ if args.root:
+ os.environ["INVOKEAI_ROOT"] = args.root
+
+ success = _list_users_json() if args.json else _list_users_table()
+ sys.exit(0 if success else 1)
+
+
+# ---------------------------------------------------------------------------
+# usermod
+# ---------------------------------------------------------------------------
+
+
+def _modify_user_interactive() -> bool:
+ """Modify a user interactively by prompting for details."""
+ from invokeai.app.services.auth.password_utils import validate_password_strength
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_common import UserUpdateRequest
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ print("=== Modify InvokeAI User ===\n")
+
+ email = input("Email address of user to modify: ").strip()
+ if not email:
+ print("Error: Email is required")
+ return False
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user = user_service.get_by_email(email)
+ if not user:
+ print(f"\n❌ Error: No user found with email '{email}'")
+ return False
+
+ print("\nCurrent user details:")
+ print(f" User ID: {user.user_id}")
+ print(f" Email: {user.email}")
+ print(f" Display Name: {user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if user.is_active else 'No'}")
+
+ print("\n--- What would you like to change? (leave blank to keep current value) ---\n")
+
+ new_name = input(f"New display name [{user.display_name or '(not set)'}]: ").strip()
+ display_name = new_name if new_name else None
+
+ change_password = input("Change password? (y/N): ").strip().lower()
+ password = None
+ if change_password in ("y", "yes"):
+ while True:
+ password = getpass.getpass("New password: ")
+ if not password:
+ print("Keeping existing password.")
+ password = None
+ break
+
+ password_confirm = getpass.getpass("Confirm new password: ")
+
+ if password != password_confirm:
+ print("Error: Passwords do not match. Please try again.\n")
+ continue
+
+ is_valid, error_msg = validate_password_strength(password)
+ if not is_valid:
+ print(f"Error: {error_msg}\n")
+ continue
+
+ break
+
+ change_admin = input("Change admin status? (y/N): ").strip().lower()
+ is_admin = None
+ if change_admin in ("y", "yes"):
+ is_admin_input = (
+ input(f"Make administrator? [current: {'Yes' if user.is_admin else 'No'}] (y/N): ").strip().lower()
+ )
+ is_admin = is_admin_input in ("y", "yes")
+
+ if display_name is None and password is None and is_admin is None:
+ print("\nNo changes requested. User not modified.")
+ return True
+
+ changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin)
+ updated_user = user_service.update(user.user_id, changes)
+
+ print("\n✅ User updated successfully!")
+ print(f" User ID: {updated_user.user_id}")
+ print(f" Email: {updated_user.email}")
+ print(f" Display Name: {updated_user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if updated_user.is_active else 'No'}")
+ return True
+
+ except ValueError as e:
+ print(f"\n❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"\n❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def _modify_user_cli(
+ email: str,
+ display_name: str | None = None,
+ password: str | None = None,
+ is_admin: bool | None = None,
+) -> bool:
+ """Modify a user via CLI arguments."""
+ from invokeai.app.services.auth.password_utils import validate_password_strength
+ from invokeai.app.services.config import get_config
+ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
+ from invokeai.app.services.users.users_common import UserUpdateRequest
+ from invokeai.app.services.users.users_default import UserService
+ from invokeai.backend.util.logging import InvokeAILogger
+
+ if password is not None:
+ is_valid, error_msg = validate_password_strength(password)
+ if not is_valid:
+ print(f"❌ Password validation failed: {error_msg}")
+ return False
+
+ try:
+ config = get_config()
+ db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
+ user_service = UserService(db)
+
+ user = user_service.get_by_email(email)
+ if not user:
+ print(f"❌ Error: No user found with email '{email}'")
+ return False
+
+ if display_name is None and password is None and is_admin is None:
+ print("❌ Error: No changes specified. Use --name, --password, --admin, or --no-admin")
+ return False
+
+ changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin)
+ updated_user = user_service.update(user.user_id, changes)
+
+ print("✅ User updated successfully!")
+ print(f" User ID: {updated_user.user_id}")
+ print(f" Email: {updated_user.email}")
+ print(f" Display Name: {updated_user.display_name or '(not set)'}")
+ print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}")
+ print(f" Active: {'Yes' if updated_user.is_active else 'No'}")
+ return True
+
+ except ValueError as e:
+ print(f"❌ Error: {e}")
+ return False
+ except Exception as e:
+ print(f"❌ Unexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ return False
+
+
+def usermod() -> None:
+ """Entry point for ``invoke-usermod``."""
+ parser = argparse.ArgumentParser(
+ description="Modify a user in the InvokeAI database",
+ epilog="If no arguments are provided, the script will run in interactive mode.",
+ )
+ parser.add_argument("--root", "-r", help=_root_help)
+ parser.add_argument("--email", "-e", help="User email address")
+ parser.add_argument("--name", "-n", help="New display name")
+ parser.add_argument("--password", "-p", help="New password")
+
+ admin_group = parser.add_mutually_exclusive_group()
+ admin_group.add_argument("--admin", "-a", action="store_true", help="Grant administrator privileges")
+ admin_group.add_argument("--no-admin", dest="no_admin", action="store_true", help="Remove administrator privileges")
+
+ args = parser.parse_args()
+
+ if args.root:
+ os.environ["INVOKEAI_ROOT"] = args.root
+
+ is_admin = None
+ if args.admin:
+ is_admin = True
+ elif args.no_admin:
+ is_admin = False
+
+ if args.email:
+ success = _modify_user_cli(args.email, args.name, args.password, is_admin)
+ else:
+ success = _modify_user_interactive()
+
+ sys.exit(0 if success else 1)
diff --git a/invokeai/backend/anima/__init__.py b/invokeai/backend/anima/__init__.py
new file mode 100644
index 00000000000..01a1a952e96
--- /dev/null
+++ b/invokeai/backend/anima/__init__.py
@@ -0,0 +1,6 @@
+"""Anima model backend module.
+
+Anima is a 2B-parameter anime-focused text-to-image model built on NVIDIA's
+Cosmos Predict2 DiT architecture with a custom LLM Adapter that bridges Qwen3
+0.6B text encoder outputs to the DiT backbone.
+"""
diff --git a/invokeai/backend/anima/anima_transformer.py b/invokeai/backend/anima/anima_transformer.py
new file mode 100644
index 00000000000..36c5764e97e
--- /dev/null
+++ b/invokeai/backend/anima/anima_transformer.py
@@ -0,0 +1,1040 @@
+"""Anima transformer model: Cosmos Predict2 MiniTrainDIT + LLM Adapter.
+
+The Anima architecture combines:
+1. MiniTrainDIT: A Cosmos Predict2 DiT backbone with 28 blocks, 2048-dim hidden state,
+ and 3D RoPE positional embeddings.
+2. LLMAdapter: A 6-layer cross-attention transformer that fuses Qwen3 0.6B hidden states
+ with learned T5-XXL token embeddings to produce conditioning for the DiT.
+
+Original source code:
+- MiniTrainDIT backbone and positional embeddings: https://github.com/nvidia-cosmos/cosmos-predict2
+ SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ SPDX-License-Identifier: Apache-2.0
+- LLMAdapter and Anima wrapper: Clean-room implementation based on
+ https://github.com/hdae/diffusers-anima (Apache-2.0)
+"""
+
+import logging
+import math
+from typing import Optional, Tuple
+
+import torch
+import torch.nn.functional as F
+from einops import rearrange, repeat
+from einops.layers.torch import Rearrange
+from torch import nn
+
+logger = logging.getLogger(__name__)
+
+
+# ============================================================================
+# Positional Embeddings
+# Original source: https://github.com/nvidia-cosmos/cosmos-predict2
+# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. Apache-2.0
+# ============================================================================
+
+
+class VideoRopePosition3DEmb(nn.Module):
+ """3D Rotary Position Embedding for video/image transformers.
+
+ Generates rotary embeddings with separate frequency components for
+ height, width, and temporal dimensions.
+ """
+
+ def __init__(
+ self,
+ *,
+ head_dim: int,
+ len_h: int,
+ len_w: int,
+ len_t: int,
+ base_fps: int = 24,
+ h_extrapolation_ratio: float = 1.0,
+ w_extrapolation_ratio: float = 1.0,
+ t_extrapolation_ratio: float = 1.0,
+ enable_fps_modulation: bool = True,
+ device: Optional[torch.device] = None,
+ **kwargs,
+ ):
+ super().__init__()
+ self.base_fps = base_fps
+ self.max_h = len_h
+ self.max_w = len_w
+ self.enable_fps_modulation = enable_fps_modulation
+
+ dim = head_dim
+ dim_h = dim // 6 * 2
+ dim_w = dim_h
+ dim_t = dim - 2 * dim_h
+ assert dim == dim_h + dim_w + dim_t, f"bad dim: {dim} != {dim_h} + {dim_w} + {dim_t}"
+
+ self.register_buffer(
+ "dim_spatial_range",
+ torch.arange(0, dim_h, 2, device=device)[: (dim_h // 2)].float() / dim_h,
+ persistent=False,
+ )
+ self.register_buffer(
+ "dim_temporal_range",
+ torch.arange(0, dim_t, 2, device=device)[: (dim_t // 2)].float() / dim_t,
+ persistent=False,
+ )
+
+ self.h_ntk_factor = h_extrapolation_ratio ** (dim_h / (dim_h - 2))
+ self.w_ntk_factor = w_extrapolation_ratio ** (dim_w / (dim_w - 2))
+ self.t_ntk_factor = t_extrapolation_ratio ** (dim_t / (dim_t - 2))
+
+ def forward(
+ self,
+ x_B_T_H_W_C: torch.Tensor,
+ fps: Optional[torch.Tensor] = None,
+ device: Optional[torch.device] = None,
+ ) -> torch.Tensor:
+ return self.generate_embeddings(x_B_T_H_W_C.shape, fps=fps, device=device)
+
+ def generate_embeddings(
+ self,
+ B_T_H_W_C: torch.Size,
+ fps: Optional[torch.Tensor] = None,
+ device: Optional[torch.device] = None,
+ dtype: Optional[torch.dtype] = None,
+ ) -> torch.Tensor:
+ h_theta = 10000.0 * self.h_ntk_factor
+ w_theta = 10000.0 * self.w_ntk_factor
+ t_theta = 10000.0 * self.t_ntk_factor
+
+ h_spatial_freqs = 1.0 / (h_theta ** self.dim_spatial_range.to(device=device))
+ w_spatial_freqs = 1.0 / (w_theta ** self.dim_spatial_range.to(device=device))
+ temporal_freqs = 1.0 / (t_theta ** self.dim_temporal_range.to(device=device))
+
+ B, T, H, W, _ = B_T_H_W_C
+ seq = torch.arange(max(H, W, T), dtype=torch.float, device=device)
+
+ half_emb_h = torch.outer(seq[:H].to(device=device), h_spatial_freqs)
+ half_emb_w = torch.outer(seq[:W].to(device=device), w_spatial_freqs)
+
+ if fps is None or self.enable_fps_modulation is False:
+ half_emb_t = torch.outer(seq[:T].to(device=device), temporal_freqs)
+ else:
+ half_emb_t = torch.outer(seq[:T].to(device=device) / fps * self.base_fps, temporal_freqs)
+
+ half_emb_h = torch.stack(
+ [torch.cos(half_emb_h), -torch.sin(half_emb_h), torch.sin(half_emb_h), torch.cos(half_emb_h)], dim=-1
+ )
+ half_emb_w = torch.stack(
+ [torch.cos(half_emb_w), -torch.sin(half_emb_w), torch.sin(half_emb_w), torch.cos(half_emb_w)], dim=-1
+ )
+ half_emb_t = torch.stack(
+ [torch.cos(half_emb_t), -torch.sin(half_emb_t), torch.sin(half_emb_t), torch.cos(half_emb_t)], dim=-1
+ )
+
+ em_T_H_W_D = torch.cat(
+ [
+ repeat(half_emb_t, "t d x -> t h w d x", h=H, w=W),
+ repeat(half_emb_h, "h d x -> t h w d x", t=T, w=W),
+ repeat(half_emb_w, "w d x -> t h w d x", t=T, h=H),
+ ],
+ dim=-2,
+ )
+
+ return rearrange(em_T_H_W_D, "t h w d (i j) -> (t h w) d i j", i=2, j=2).float()
+
+
+def _normalize(x: torch.Tensor, dim: Optional[list[int]] = None, eps: float = 0) -> torch.Tensor:
+ if dim is None:
+ dim = list(range(1, x.ndim))
+ norm = torch.linalg.vector_norm(x, dim=dim, keepdim=True, dtype=torch.float32)
+ norm = torch.add(eps, norm, alpha=math.sqrt(norm.numel() / x.numel()))
+ return x / norm.to(x.dtype)
+
+
+class LearnablePosEmbAxis(nn.Module):
+ """Learnable per-axis positional embeddings."""
+
+ def __init__(
+ self,
+ *,
+ model_channels: int,
+ len_h: int,
+ len_w: int,
+ len_t: int,
+ device: Optional[torch.device] = None,
+ dtype: Optional[torch.dtype] = None,
+ **kwargs,
+ ):
+ super().__init__()
+ self.pos_emb_h = nn.Parameter(torch.empty(len_h, model_channels, device=device, dtype=dtype))
+ self.pos_emb_w = nn.Parameter(torch.empty(len_w, model_channels, device=device, dtype=dtype))
+ self.pos_emb_t = nn.Parameter(torch.empty(len_t, model_channels, device=device, dtype=dtype))
+
+ def forward(
+ self,
+ x_B_T_H_W_C: torch.Tensor,
+ fps: Optional[torch.Tensor] = None,
+ device: Optional[torch.device] = None,
+ dtype: Optional[torch.dtype] = None,
+ ) -> torch.Tensor:
+ return self.generate_embeddings(x_B_T_H_W_C.shape, device=device, dtype=dtype)
+
+ def generate_embeddings(
+ self,
+ B_T_H_W_C: torch.Size,
+ fps: Optional[torch.Tensor] = None,
+ device: Optional[torch.device] = None,
+ dtype: Optional[torch.dtype] = None,
+ ) -> torch.Tensor:
+ B, T, H, W, _ = B_T_H_W_C
+ emb_h_H = self.pos_emb_h[:H].to(device=device, dtype=dtype)
+ emb_w_W = self.pos_emb_w[:W].to(device=device, dtype=dtype)
+ emb_t_T = self.pos_emb_t[:T].to(device=device, dtype=dtype)
+ emb = (
+ repeat(emb_t_T, "t d -> b t h w d", b=B, h=H, w=W)
+ + repeat(emb_h_H, "h d -> b t h w d", b=B, t=T, w=W)
+ + repeat(emb_w_W, "w d -> b t h w d", b=B, t=T, h=H)
+ )
+ return _normalize(emb, dim=-1, eps=1e-6)
+
+
+# ============================================================================
+# Cosmos Predict2 MiniTrainDIT
+# Original source: https://github.com/nvidia-cosmos/cosmos-predict2
+# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. Apache-2.0
+# ============================================================================
+
+
+def apply_rotary_pos_emb_cosmos(t: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor:
+ """Apply rotary position embeddings in Cosmos format (2x2 rotation matrices)."""
+ t_ = t.reshape(*t.shape[:-1], 2, -1).movedim(-2, -1).unsqueeze(-2).float()
+ t_out = freqs[..., 0] * t_[..., 0] + freqs[..., 1] * t_[..., 1]
+ t_out = t_out.movedim(-1, -2).reshape(*t.shape).type_as(t)
+ return t_out
+
+
+class GPT2FeedForward(nn.Module):
+ def __init__(self, d_model: int, d_ff: int) -> None:
+ super().__init__()
+ self.activation = nn.GELU()
+ self.layer1 = nn.Linear(d_model, d_ff, bias=False)
+ self.layer2 = nn.Linear(d_ff, d_model, bias=False)
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ return self.layer2(self.activation(self.layer1(x)))
+
+
+class CosmosAttention(nn.Module):
+ """Multi-head attention for the Cosmos DiT backbone.
+
+ Supports both self-attention and cross-attention with QK normalization
+ and rotary position embeddings.
+ """
+
+ def __init__(
+ self,
+ query_dim: int,
+ context_dim: Optional[int] = None,
+ n_heads: int = 8,
+ head_dim: int = 64,
+ dropout: float = 0.0,
+ ) -> None:
+ super().__init__()
+ self.is_selfattn = context_dim is None
+ context_dim = query_dim if context_dim is None else context_dim
+ inner_dim = head_dim * n_heads
+
+ self.n_heads = n_heads
+ self.head_dim = head_dim
+
+ self.q_proj = nn.Linear(query_dim, inner_dim, bias=False)
+ self.q_norm = nn.RMSNorm(head_dim, eps=1e-6)
+
+ self.k_proj = nn.Linear(context_dim, inner_dim, bias=False)
+ self.k_norm = nn.RMSNorm(head_dim, eps=1e-6)
+
+ self.v_proj = nn.Linear(context_dim, inner_dim, bias=False)
+ self.v_norm = nn.Identity()
+
+ self.output_proj = nn.Linear(inner_dim, query_dim, bias=False)
+ self.output_dropout = nn.Dropout(dropout) if dropout > 1e-4 else nn.Identity()
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ context: Optional[torch.Tensor] = None,
+ rope_emb: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ q = self.q_proj(x)
+ context = x if context is None else context
+ k = self.k_proj(context)
+ v = self.v_proj(context)
+ q, k, v = (rearrange(t, "b ... (h d) -> b ... h d", h=self.n_heads, d=self.head_dim) for t in (q, k, v))
+
+ q = self.q_norm(q)
+ k = self.k_norm(k)
+ v = self.v_norm(v)
+
+ if self.is_selfattn and rope_emb is not None:
+ q = apply_rotary_pos_emb_cosmos(q, rope_emb)
+ k = apply_rotary_pos_emb_cosmos(k, rope_emb)
+
+ # Reshape for scaled_dot_product_attention: (B, heads, seq, dim)
+ in_q_shape = q.shape
+ in_k_shape = k.shape
+ q = rearrange(q, "b ... h d -> b h ... d").reshape(in_q_shape[0], in_q_shape[-2], -1, in_q_shape[-1])
+ k = rearrange(k, "b ... h d -> b h ... d").reshape(in_k_shape[0], in_k_shape[-2], -1, in_k_shape[-1])
+ v = rearrange(v, "b ... h d -> b h ... d").reshape(in_k_shape[0], in_k_shape[-2], -1, in_k_shape[-1])
+
+ result = F.scaled_dot_product_attention(q, k, v)
+ result = rearrange(result, "b h s d -> b s (h d)")
+ return self.output_dropout(self.output_proj(result))
+
+
+class Timesteps(nn.Module):
+ """Sinusoidal timestep embeddings."""
+
+ def __init__(self, num_channels: int):
+ super().__init__()
+ self.num_channels = num_channels
+
+ def forward(self, timesteps_B_T: torch.Tensor) -> torch.Tensor:
+ assert timesteps_B_T.ndim == 2
+ timesteps = timesteps_B_T.flatten().float()
+ half_dim = self.num_channels // 2
+ exponent = -math.log(10000) * torch.arange(half_dim, dtype=torch.float32, device=timesteps.device) / half_dim
+ emb = timesteps[:, None].float() * torch.exp(exponent)[None, :]
+ emb = torch.cat([torch.cos(emb), torch.sin(emb)], dim=-1)
+ return rearrange(emb, "(b t) d -> b t d", b=timesteps_B_T.shape[0], t=timesteps_B_T.shape[1])
+
+
+class TimestepEmbedding(nn.Module):
+ """Projects sinusoidal timestep embeddings to model dimension."""
+
+ def __init__(self, in_features: int, out_features: int, use_adaln_lora: bool = False):
+ super().__init__()
+ self.use_adaln_lora = use_adaln_lora
+ self.linear_1 = nn.Linear(in_features, out_features, bias=not use_adaln_lora)
+ self.activation = nn.SiLU()
+ if use_adaln_lora:
+ self.linear_2 = nn.Linear(out_features, 3 * out_features, bias=False)
+ else:
+ self.linear_2 = nn.Linear(out_features, out_features, bias=False)
+
+ def forward(self, sample: torch.Tensor) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
+ emb = self.linear_2(self.activation(self.linear_1(sample)))
+ if self.use_adaln_lora:
+ return sample, emb
+ return emb, None
+
+
+class PatchEmbed(nn.Module):
+ """Patchify input tensor via rearrange + linear projection."""
+
+ def __init__(
+ self,
+ spatial_patch_size: int,
+ temporal_patch_size: int,
+ in_channels: int = 3,
+ out_channels: int = 768,
+ ):
+ super().__init__()
+ self.spatial_patch_size = spatial_patch_size
+ self.temporal_patch_size = temporal_patch_size
+ self.proj = nn.Sequential(
+ Rearrange(
+ "b c (t r) (h m) (w n) -> b t h w (c r m n)",
+ r=temporal_patch_size,
+ m=spatial_patch_size,
+ n=spatial_patch_size,
+ ),
+ nn.Linear(
+ in_channels * spatial_patch_size * spatial_patch_size * temporal_patch_size,
+ out_channels,
+ bias=False,
+ ),
+ )
+
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
+ assert x.dim() == 5
+ return self.proj(x)
+
+
+class FinalLayer(nn.Module):
+ """Final AdaLN-modulated output projection."""
+
+ def __init__(
+ self,
+ hidden_size: int,
+ spatial_patch_size: int,
+ temporal_patch_size: int,
+ out_channels: int,
+ use_adaln_lora: bool = False,
+ adaln_lora_dim: int = 256,
+ ):
+ super().__init__()
+ self.layer_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
+ self.linear = nn.Linear(
+ hidden_size, spatial_patch_size * spatial_patch_size * temporal_patch_size * out_channels, bias=False
+ )
+ self.hidden_size = hidden_size
+ self.use_adaln_lora = use_adaln_lora
+
+ if use_adaln_lora:
+ self.adaln_modulation = nn.Sequential(
+ nn.SiLU(),
+ nn.Linear(hidden_size, adaln_lora_dim, bias=False),
+ nn.Linear(adaln_lora_dim, 2 * hidden_size, bias=False),
+ )
+ else:
+ self.adaln_modulation = nn.Sequential(
+ nn.SiLU(),
+ nn.Linear(hidden_size, 2 * hidden_size, bias=False),
+ )
+
+ def forward(
+ self,
+ x_B_T_H_W_D: torch.Tensor,
+ emb_B_T_D: torch.Tensor,
+ adaln_lora_B_T_3D: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ if self.use_adaln_lora:
+ assert adaln_lora_B_T_3D is not None
+ shift, scale = (self.adaln_modulation(emb_B_T_D) + adaln_lora_B_T_3D[:, :, : 2 * self.hidden_size]).chunk(
+ 2, dim=-1
+ )
+ else:
+ shift, scale = self.adaln_modulation(emb_B_T_D).chunk(2, dim=-1)
+
+ shift = rearrange(shift, "b t d -> b t 1 1 d")
+ scale = rearrange(scale, "b t d -> b t 1 1 d")
+
+ x_B_T_H_W_D = self.layer_norm(x_B_T_H_W_D) * (1 + scale) + shift
+ return self.linear(x_B_T_H_W_D)
+
+
+class DiTBlock(nn.Module):
+ """Cosmos DiT transformer block with self-attention, cross-attention, and MLP.
+
+ Each component uses AdaLN (Adaptive Layer Normalization) modulation from
+ the timestep embedding.
+ """
+
+ def __init__(
+ self,
+ x_dim: int,
+ context_dim: int,
+ num_heads: int,
+ mlp_ratio: float = 4.0,
+ use_adaln_lora: bool = False,
+ adaln_lora_dim: int = 256,
+ ):
+ super().__init__()
+ self.x_dim = x_dim
+ self.use_adaln_lora = use_adaln_lora
+
+ self.layer_norm_self_attn = nn.LayerNorm(x_dim, elementwise_affine=False, eps=1e-6)
+ self.self_attn = CosmosAttention(x_dim, None, num_heads, x_dim // num_heads)
+
+ self.layer_norm_cross_attn = nn.LayerNorm(x_dim, elementwise_affine=False, eps=1e-6)
+ self.cross_attn = CosmosAttention(x_dim, context_dim, num_heads, x_dim // num_heads)
+
+ self.layer_norm_mlp = nn.LayerNorm(x_dim, elementwise_affine=False, eps=1e-6)
+ self.mlp = GPT2FeedForward(x_dim, int(x_dim * mlp_ratio))
+
+ # AdaLN modulation layers (shift, scale, gate for each of 3 components)
+ if use_adaln_lora:
+ self.adaln_modulation_self_attn = nn.Sequential(
+ nn.SiLU(),
+ nn.Linear(x_dim, adaln_lora_dim, bias=False),
+ nn.Linear(adaln_lora_dim, 3 * x_dim, bias=False),
+ )
+ self.adaln_modulation_cross_attn = nn.Sequential(
+ nn.SiLU(),
+ nn.Linear(x_dim, adaln_lora_dim, bias=False),
+ nn.Linear(adaln_lora_dim, 3 * x_dim, bias=False),
+ )
+ self.adaln_modulation_mlp = nn.Sequential(
+ nn.SiLU(),
+ nn.Linear(x_dim, adaln_lora_dim, bias=False),
+ nn.Linear(adaln_lora_dim, 3 * x_dim, bias=False),
+ )
+ else:
+ self.adaln_modulation_self_attn = nn.Sequential(nn.SiLU(), nn.Linear(x_dim, 3 * x_dim, bias=False))
+ self.adaln_modulation_cross_attn = nn.Sequential(nn.SiLU(), nn.Linear(x_dim, 3 * x_dim, bias=False))
+ self.adaln_modulation_mlp = nn.Sequential(nn.SiLU(), nn.Linear(x_dim, 3 * x_dim, bias=False))
+
+ def forward(
+ self,
+ x_B_T_H_W_D: torch.Tensor,
+ emb_B_T_D: torch.Tensor,
+ crossattn_emb: torch.Tensor,
+ rope_emb_L_1_1_D: Optional[torch.Tensor] = None,
+ adaln_lora_B_T_3D: Optional[torch.Tensor] = None,
+ extra_per_block_pos_emb: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ residual_dtype = x_B_T_H_W_D.dtype
+ compute_dtype = emb_B_T_D.dtype
+
+ if extra_per_block_pos_emb is not None:
+ x_B_T_H_W_D = x_B_T_H_W_D + extra_per_block_pos_emb
+
+ # Compute AdaLN modulations
+ if self.use_adaln_lora:
+ assert adaln_lora_B_T_3D is not None
+ shift_sa, scale_sa, gate_sa = (self.adaln_modulation_self_attn(emb_B_T_D) + adaln_lora_B_T_3D).chunk(
+ 3, dim=-1
+ )
+ shift_ca, scale_ca, gate_ca = (self.adaln_modulation_cross_attn(emb_B_T_D) + adaln_lora_B_T_3D).chunk(
+ 3, dim=-1
+ )
+ shift_mlp, scale_mlp, gate_mlp = (self.adaln_modulation_mlp(emb_B_T_D) + adaln_lora_B_T_3D).chunk(3, dim=-1)
+ else:
+ shift_sa, scale_sa, gate_sa = self.adaln_modulation_self_attn(emb_B_T_D).chunk(3, dim=-1)
+ shift_ca, scale_ca, gate_ca = self.adaln_modulation_cross_attn(emb_B_T_D).chunk(3, dim=-1)
+ shift_mlp, scale_mlp, gate_mlp = self.adaln_modulation_mlp(emb_B_T_D).chunk(3, dim=-1)
+
+ # Reshape for broadcasting: (B, T, D) -> (B, T, 1, 1, D)
+ shift_sa, scale_sa, gate_sa = (rearrange(t, "b t d -> b t 1 1 d") for t in (shift_sa, scale_sa, gate_sa))
+ shift_ca, scale_ca, gate_ca = (rearrange(t, "b t d -> b t 1 1 d") for t in (shift_ca, scale_ca, gate_ca))
+ shift_mlp, scale_mlp, gate_mlp = (rearrange(t, "b t d -> b t 1 1 d") for t in (shift_mlp, scale_mlp, gate_mlp))
+
+ B, T, H, W, D = x_B_T_H_W_D.shape
+
+ def _adaln(x: torch.Tensor, norm: nn.Module, scale: torch.Tensor, shift: torch.Tensor) -> torch.Tensor:
+ return norm(x) * (1 + scale) + shift
+
+ # Self-attention
+ normed = _adaln(x_B_T_H_W_D, self.layer_norm_self_attn, scale_sa, shift_sa)
+ result = rearrange(
+ self.self_attn(
+ rearrange(normed.to(compute_dtype), "b t h w d -> b (t h w) d"), None, rope_emb=rope_emb_L_1_1_D
+ ),
+ "b (t h w) d -> b t h w d",
+ t=T,
+ h=H,
+ w=W,
+ )
+ x_B_T_H_W_D = x_B_T_H_W_D + gate_sa.to(residual_dtype) * result.to(residual_dtype)
+
+ # Cross-attention
+ normed = _adaln(x_B_T_H_W_D, self.layer_norm_cross_attn, scale_ca, shift_ca)
+ result = rearrange(
+ self.cross_attn(
+ rearrange(normed.to(compute_dtype), "b t h w d -> b (t h w) d"),
+ crossattn_emb,
+ rope_emb=rope_emb_L_1_1_D,
+ ),
+ "b (t h w) d -> b t h w d",
+ t=T,
+ h=H,
+ w=W,
+ )
+ x_B_T_H_W_D = result.to(residual_dtype) * gate_ca.to(residual_dtype) + x_B_T_H_W_D
+
+ # MLP
+ normed = _adaln(x_B_T_H_W_D, self.layer_norm_mlp, scale_mlp, shift_mlp)
+ result = self.mlp(normed.to(compute_dtype))
+ x_B_T_H_W_D = x_B_T_H_W_D + gate_mlp.to(residual_dtype) * result.to(residual_dtype)
+
+ return x_B_T_H_W_D
+
+
+class MiniTrainDIT(nn.Module):
+ """Cosmos Predict2 DiT backbone for video/image generation.
+
+ This is the core transformer architecture that Anima extends. It processes
+ 3D latent tensors (B, C, T, H, W) with patch embedding, positional encoding,
+ and adaptive layer normalization.
+
+ Args:
+ max_img_h: Maximum image height in pixels.
+ max_img_w: Maximum image width in pixels.
+ max_frames: Maximum number of video frames.
+ in_channels: Number of input latent channels.
+ out_channels: Number of output channels.
+ patch_spatial: Spatial patch size.
+ patch_temporal: Temporal patch size.
+ concat_padding_mask: Whether to concatenate a padding mask channel.
+ model_channels: Hidden dimension of the transformer.
+ num_blocks: Number of DiT blocks.
+ num_heads: Number of attention heads.
+ mlp_ratio: MLP expansion ratio.
+ crossattn_emb_channels: Cross-attention context dimension.
+ use_adaln_lora: Whether to use AdaLN-LoRA.
+ adaln_lora_dim: AdaLN-LoRA bottleneck dimension.
+ extra_per_block_abs_pos_emb: Whether to use extra learnable positional embeddings.
+ """
+
+ def __init__(
+ self,
+ max_img_h: int = 240,
+ max_img_w: int = 240,
+ max_frames: int = 1,
+ in_channels: int = 16,
+ out_channels: int = 16,
+ patch_spatial: int = 2,
+ patch_temporal: int = 1,
+ concat_padding_mask: bool = True,
+ model_channels: int = 2048,
+ num_blocks: int = 28,
+ num_heads: int = 16,
+ mlp_ratio: float = 4.0,
+ crossattn_emb_channels: int = 1024,
+ pos_emb_cls: str = "rope3d",
+ pos_emb_learnable: bool = False,
+ pos_emb_interpolation: str = "crop",
+ min_fps: int = 1,
+ max_fps: int = 30,
+ use_adaln_lora: bool = False,
+ adaln_lora_dim: int = 256,
+ rope_h_extrapolation_ratio: float = 1.0,
+ rope_w_extrapolation_ratio: float = 1.0,
+ rope_t_extrapolation_ratio: float = 1.0,
+ extra_per_block_abs_pos_emb: bool = False,
+ extra_h_extrapolation_ratio: float = 1.0,
+ extra_w_extrapolation_ratio: float = 1.0,
+ extra_t_extrapolation_ratio: float = 1.0,
+ rope_enable_fps_modulation: bool = True,
+ image_model: Optional[str] = None,
+ ) -> None:
+ super().__init__()
+ self.max_img_h = max_img_h
+ self.max_img_w = max_img_w
+ self.max_frames = max_frames
+ self.in_channels = in_channels
+ self.out_channels = out_channels
+ self.patch_spatial = patch_spatial
+ self.patch_temporal = patch_temporal
+ self.num_heads = num_heads
+ self.num_blocks = num_blocks
+ self.model_channels = model_channels
+ self.concat_padding_mask = concat_padding_mask
+ self.pos_emb_cls = pos_emb_cls
+ self.extra_per_block_abs_pos_emb = extra_per_block_abs_pos_emb
+
+ # Positional embeddings
+ self.pos_embedder = VideoRopePosition3DEmb(
+ head_dim=model_channels // num_heads,
+ len_h=max_img_h // patch_spatial,
+ len_w=max_img_w // patch_spatial,
+ len_t=max_frames // patch_temporal,
+ max_fps=max_fps,
+ min_fps=min_fps,
+ h_extrapolation_ratio=rope_h_extrapolation_ratio,
+ w_extrapolation_ratio=rope_w_extrapolation_ratio,
+ t_extrapolation_ratio=rope_t_extrapolation_ratio,
+ enable_fps_modulation=rope_enable_fps_modulation,
+ )
+
+ if extra_per_block_abs_pos_emb:
+ self.extra_pos_embedder = LearnablePosEmbAxis(
+ model_channels=model_channels,
+ len_h=max_img_h // patch_spatial,
+ len_w=max_img_w // patch_spatial,
+ len_t=max_frames // patch_temporal,
+ )
+
+ self.use_adaln_lora = use_adaln_lora
+ self.adaln_lora_dim = adaln_lora_dim
+
+ # Timestep embedding
+ self.t_embedder = nn.Sequential(
+ Timesteps(model_channels),
+ TimestepEmbedding(model_channels, model_channels, use_adaln_lora=use_adaln_lora),
+ )
+ self.t_embedding_norm = nn.RMSNorm(model_channels, eps=1e-6)
+
+ # Patch embedding
+ embed_in_channels = in_channels + 1 if concat_padding_mask else in_channels
+ self.x_embedder = PatchEmbed(
+ spatial_patch_size=patch_spatial,
+ temporal_patch_size=patch_temporal,
+ in_channels=embed_in_channels,
+ out_channels=model_channels,
+ )
+
+ # Transformer blocks
+ self.blocks = nn.ModuleList(
+ [
+ DiTBlock(
+ x_dim=model_channels,
+ context_dim=crossattn_emb_channels,
+ num_heads=num_heads,
+ mlp_ratio=mlp_ratio,
+ use_adaln_lora=use_adaln_lora,
+ adaln_lora_dim=adaln_lora_dim,
+ )
+ for _ in range(num_blocks)
+ ]
+ )
+
+ # Final output layer
+ self.final_layer = FinalLayer(
+ hidden_size=model_channels,
+ spatial_patch_size=patch_spatial,
+ temporal_patch_size=patch_temporal,
+ out_channels=out_channels,
+ use_adaln_lora=use_adaln_lora,
+ adaln_lora_dim=adaln_lora_dim,
+ )
+
+ def _pad_to_patch_size(self, x: torch.Tensor) -> torch.Tensor:
+ """Pad input tensor so dimensions are divisible by patch sizes."""
+ _, _, T, H, W = x.shape
+ pad_t = (self.patch_temporal - T % self.patch_temporal) % self.patch_temporal
+ pad_h = (self.patch_spatial - H % self.patch_spatial) % self.patch_spatial
+ pad_w = (self.patch_spatial - W % self.patch_spatial) % self.patch_spatial
+ if pad_t > 0 or pad_h > 0 or pad_w > 0:
+ x = F.pad(x, (0, pad_w, 0, pad_h, 0, pad_t))
+ return x
+
+ def prepare_embedded_sequence(
+ self,
+ x_B_C_T_H_W: torch.Tensor,
+ fps: Optional[torch.Tensor] = None,
+ padding_mask: Optional[torch.Tensor] = None,
+ ) -> Tuple[torch.Tensor, Optional[torch.Tensor], Optional[torch.Tensor]]:
+ if self.concat_padding_mask:
+ if padding_mask is None:
+ padding_mask = torch.zeros(
+ x_B_C_T_H_W.shape[0],
+ 1,
+ x_B_C_T_H_W.shape[3],
+ x_B_C_T_H_W.shape[4],
+ dtype=x_B_C_T_H_W.dtype,
+ device=x_B_C_T_H_W.device,
+ )
+ x_B_C_T_H_W = torch.cat(
+ [x_B_C_T_H_W, padding_mask.unsqueeze(1).repeat(1, 1, x_B_C_T_H_W.shape[2], 1, 1)], dim=1
+ )
+
+ x_B_T_H_W_D = self.x_embedder(x_B_C_T_H_W)
+
+ extra_pos_emb = None
+ if self.extra_per_block_abs_pos_emb:
+ extra_pos_emb = self.extra_pos_embedder(
+ x_B_T_H_W_D, fps=fps, device=x_B_C_T_H_W.device, dtype=x_B_C_T_H_W.dtype
+ )
+
+ if "rope" in self.pos_emb_cls.lower():
+ return x_B_T_H_W_D, self.pos_embedder(x_B_T_H_W_D, fps=fps, device=x_B_C_T_H_W.device), extra_pos_emb
+
+ return x_B_T_H_W_D, None, extra_pos_emb
+
+ def unpatchify(self, x_B_T_H_W_M: torch.Tensor) -> torch.Tensor:
+ return rearrange(
+ x_B_T_H_W_M,
+ "B T H W (p1 p2 t C) -> B C (T t) (H p1) (W p2)",
+ p1=self.patch_spatial,
+ p2=self.patch_spatial,
+ t=self.patch_temporal,
+ )
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ timesteps: torch.Tensor,
+ context: torch.Tensor,
+ fps: Optional[torch.Tensor] = None,
+ padding_mask: Optional[torch.Tensor] = None,
+ **kwargs,
+ ) -> torch.Tensor:
+ orig_shape = list(x.shape)
+ x = self._pad_to_patch_size(x)
+
+ x_B_T_H_W_D, rope_emb_L_1_1_D, extra_pos_emb = self.prepare_embedded_sequence(
+ x, fps=fps, padding_mask=padding_mask
+ )
+
+ if timesteps.ndim == 1:
+ timesteps = timesteps.unsqueeze(1)
+ t_emb, adaln_lora = self.t_embedder[1](self.t_embedder[0](timesteps).to(x_B_T_H_W_D.dtype))
+ t_emb = self.t_embedding_norm(t_emb)
+
+ block_kwargs = {
+ "rope_emb_L_1_1_D": rope_emb_L_1_1_D.unsqueeze(1).unsqueeze(0) if rope_emb_L_1_1_D is not None else None,
+ "adaln_lora_B_T_3D": adaln_lora,
+ "extra_per_block_pos_emb": extra_pos_emb,
+ }
+
+ # Keep residual stream in fp32 for numerical stability with fp16 compute
+ if x_B_T_H_W_D.dtype == torch.float16:
+ x_B_T_H_W_D = x_B_T_H_W_D.float()
+
+ for block in self.blocks:
+ x_B_T_H_W_D = block(x_B_T_H_W_D, t_emb, context, **block_kwargs)
+
+ x_out = self.final_layer(x_B_T_H_W_D.to(context.dtype), t_emb, adaln_lora_B_T_3D=adaln_lora)
+ x_out = self.unpatchify(x_out)[:, :, : orig_shape[-3], : orig_shape[-2], : orig_shape[-1]]
+ return x_out
+
+
+# ============================================================================
+# LLM Adapter
+# Reference implementation: https://github.com/hdae/diffusers-anima
+# SPDX-License-Identifier: Apache-2.0
+# ============================================================================
+
+
+def _rotate_half(x: torch.Tensor) -> torch.Tensor:
+ """Split the last dimension in half and negate-swap: [-x2, x1]."""
+ half = x.shape[-1] // 2
+ first, second = x[..., :half], x[..., half:]
+ return torch.cat((-second, first), dim=-1)
+
+
+def _apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
+ """Apply rotary position embeddings to tensor x given precomputed cos/sin."""
+ return (x * cos.unsqueeze(1)) + (_rotate_half(x) * sin.unsqueeze(1))
+
+
+class LLMAdapterRotaryEmbedding(nn.Module):
+ """Rotary position embedding for the LLM Adapter's attention layers."""
+
+ def __init__(self, head_dim: int, theta: float = 10000.0):
+ super().__init__()
+ half_dim = head_dim // 2
+ index = torch.arange(half_dim, dtype=torch.float32)
+ exponent = (2.0 / float(head_dim)) * index
+ inv_freq = torch.reciprocal(torch.pow(torch.tensor(theta, dtype=torch.float32), exponent))
+ self.register_buffer("inv_freq", inv_freq, persistent=False)
+
+ def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
+ pos = position_ids.to(device=x.device, dtype=torch.float32)
+ inv = self.inv_freq.to(device=x.device, dtype=torch.float32)
+ freqs = torch.einsum("bl,d->bld", pos, inv)
+ emb = freqs.repeat(1, 1, 2)
+ return emb.cos().to(dtype=x.dtype), emb.sin().to(dtype=x.dtype)
+
+
+class LLMAdapterAttention(nn.Module):
+ """Attention for the LLM Adapter with QK normalization and rotary position embeddings."""
+
+ def __init__(self, query_dim: int, context_dim: int, n_heads: int, head_dim: int):
+ super().__init__()
+ inner_dim = head_dim * n_heads
+ self.n_heads = n_heads
+ self.head_dim = head_dim
+
+ self.q_proj = nn.Linear(query_dim, inner_dim, bias=False)
+ self.q_norm = nn.RMSNorm(head_dim, eps=1e-6)
+ self.k_proj = nn.Linear(context_dim, inner_dim, bias=False)
+ self.k_norm = nn.RMSNorm(head_dim, eps=1e-6)
+ self.v_proj = nn.Linear(context_dim, inner_dim, bias=False)
+ self.o_proj = nn.Linear(inner_dim, query_dim, bias=False)
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ *,
+ context: Optional[torch.Tensor] = None,
+ attn_mask: Optional[torch.Tensor] = None,
+ pos_q: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
+ pos_k: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
+ ) -> torch.Tensor:
+ context = x if context is None else context
+
+ q = self.q_proj(x).view(x.shape[0], x.shape[1], self.n_heads, self.head_dim).transpose(1, 2)
+ k = self.k_proj(context).view(context.shape[0], context.shape[1], self.n_heads, self.head_dim).transpose(1, 2)
+ v = self.v_proj(context).view(context.shape[0], context.shape[1], self.n_heads, self.head_dim).transpose(1, 2)
+
+ q = self.q_norm(q)
+ k = self.k_norm(k)
+
+ if pos_q is not None and pos_k is not None:
+ q = _apply_rope(q, *pos_q)
+ k = _apply_rope(k, *pos_k)
+
+ y = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask)
+ y = y.transpose(1, 2).reshape(x.shape[0], x.shape[1], -1).contiguous()
+ return self.o_proj(y)
+
+
+class LLMAdapterTransformerBlock(nn.Module):
+ """Single transformer block in the LLM Adapter.
+
+ Each block contains self-attention, cross-attention, and MLP with
+ RMSNorm pre-normalization.
+ """
+
+ def __init__(
+ self,
+ source_dim: int,
+ model_dim: int,
+ num_heads: int = 16,
+ ):
+ super().__init__()
+ head_dim = model_dim // num_heads
+
+ self.norm_self_attn = nn.RMSNorm(model_dim, eps=1e-6)
+ self.self_attn = LLMAdapterAttention(model_dim, model_dim, num_heads, head_dim)
+
+ self.norm_cross_attn = nn.RMSNorm(model_dim, eps=1e-6)
+ self.cross_attn = LLMAdapterAttention(model_dim, source_dim, num_heads, head_dim)
+
+ self.norm_mlp = nn.RMSNorm(model_dim, eps=1e-6)
+ self.mlp = nn.Sequential(
+ nn.Linear(model_dim, model_dim * 4),
+ nn.GELU(),
+ nn.Linear(model_dim * 4, model_dim),
+ )
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ *,
+ context: torch.Tensor,
+ target_mask: Optional[torch.Tensor] = None,
+ source_mask: Optional[torch.Tensor] = None,
+ pos_target: Tuple[torch.Tensor, torch.Tensor],
+ pos_source: Tuple[torch.Tensor, torch.Tensor],
+ ) -> torch.Tensor:
+ x = x + self.self_attn(
+ self.norm_self_attn(x),
+ attn_mask=target_mask,
+ pos_q=pos_target,
+ pos_k=pos_target,
+ )
+ x = x + self.cross_attn(
+ self.norm_cross_attn(x),
+ context=context,
+ attn_mask=source_mask,
+ pos_q=pos_target,
+ pos_k=pos_source,
+ )
+ x = x + self.mlp(self.norm_mlp(x))
+ return x
+
+
+class LLMAdapter(nn.Module):
+ """LLM Adapter: bridges Qwen3 hidden states and T5-XXL token embeddings.
+
+ Takes Qwen3 hidden states and T5-XXL token IDs, produces conditioning
+ embeddings for the Cosmos DiT via cross-attention through 6 transformer layers.
+
+ Args:
+ vocab_size: Size of the T5 token vocabulary.
+ dim: Model dimension (used for embeddings, projections, and all layers).
+ num_layers: Number of transformer layers.
+ num_heads: Number of attention heads.
+ """
+
+ def __init__(
+ self,
+ vocab_size: int = 32128,
+ dim: int = 1024,
+ num_layers: int = 6,
+ num_heads: int = 16,
+ ):
+ super().__init__()
+ self.embed = nn.Embedding(vocab_size, dim)
+ self.blocks = nn.ModuleList(
+ [LLMAdapterTransformerBlock(source_dim=dim, model_dim=dim, num_heads=num_heads) for _ in range(num_layers)]
+ )
+ self.out_proj = nn.Linear(dim, dim)
+ self.norm = nn.RMSNorm(dim, eps=1e-6)
+ self.rotary_emb = LLMAdapterRotaryEmbedding(dim // num_heads)
+
+ def forward(
+ self,
+ source_hidden_states: torch.Tensor,
+ target_input_ids: torch.Tensor,
+ target_attention_mask: Optional[torch.Tensor] = None,
+ source_attention_mask: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ # Expand attention masks for multi-head attention
+ if target_attention_mask is not None:
+ target_attention_mask = target_attention_mask.to(torch.bool)
+ if target_attention_mask.ndim == 2:
+ target_attention_mask = target_attention_mask[:, None, None, :]
+
+ if source_attention_mask is not None:
+ source_attention_mask = source_attention_mask.to(torch.bool)
+ if source_attention_mask.ndim == 2:
+ source_attention_mask = source_attention_mask[:, None, None, :]
+
+ context = source_hidden_states
+ x = self.embed(target_input_ids).to(dtype=context.dtype)
+
+ # Build position IDs and compute rotary embeddings
+ target_pos_ids = torch.arange(x.shape[1], device=x.device, dtype=torch.long).unsqueeze(0)
+ source_pos_ids = torch.arange(context.shape[1], device=x.device, dtype=torch.long).unsqueeze(0)
+ pos_target = self.rotary_emb(x, target_pos_ids)
+ pos_source = self.rotary_emb(x, source_pos_ids)
+
+ for block in self.blocks:
+ x = block(
+ x,
+ context=context,
+ target_mask=target_attention_mask,
+ source_mask=source_attention_mask,
+ pos_target=pos_target,
+ pos_source=pos_source,
+ )
+ return self.norm(self.out_proj(x))
+
+
+# ============================================================================
+# Anima: MiniTrainDIT + LLMAdapter
+# Reference implementation: https://github.com/hdae/diffusers-anima
+# SPDX-License-Identifier: Apache-2.0
+# ============================================================================
+
+
+class AnimaTransformer(MiniTrainDIT):
+ """Anima transformer: Cosmos Predict2 DiT with integrated LLM Adapter.
+
+ Extends MiniTrainDIT by adding the LLMAdapter component that preprocesses
+ text embeddings before they are fed to the DiT cross-attention layers.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.llm_adapter = LLMAdapter()
+
+ def preprocess_text_embeds(
+ self,
+ text_embeds: torch.Tensor,
+ text_ids: Optional[torch.Tensor],
+ t5xxl_weights: Optional[torch.Tensor] = None,
+ ) -> torch.Tensor:
+ """Run the LLM Adapter to produce conditioning for the DiT.
+
+ Args:
+ text_embeds: Qwen3 hidden states. Shape: (batch, seq_len, 1024).
+ text_ids: T5-XXL token IDs. Shape: (batch, seq_len). If None, returns text_embeds directly.
+ t5xxl_weights: Optional per-token weights. Shape: (batch, seq_len, 1).
+
+ Returns:
+ Conditioning tensor. Shape: (batch, 512, 1024), zero-padded if needed.
+ """
+ if text_ids is None:
+ return text_embeds
+ out = self.llm_adapter(text_embeds, text_ids)
+ if t5xxl_weights is not None:
+ out = out * t5xxl_weights
+ if out.shape[1] < 512:
+ out = F.pad(out, (0, 0, 0, 512 - out.shape[1]))
+ return out
+
+ def forward(
+ self,
+ x: torch.Tensor,
+ timesteps: torch.Tensor,
+ context: torch.Tensor,
+ t5xxl_ids: Optional[torch.Tensor] = None,
+ t5xxl_weights: Optional[torch.Tensor] = None,
+ **kwargs,
+ ) -> torch.Tensor:
+ """Forward pass with LLM Adapter preprocessing.
+
+ Args:
+ x: Input latent tensor. Shape: (B, C, T, H, W).
+ timesteps: Timestep values. Shape: (B,) or (B, T).
+ context: Qwen3 hidden states. Shape: (B, seq_len, 1024).
+ t5xxl_ids: T5-XXL token IDs. Shape: (B, seq_len).
+ t5xxl_weights: Per-token weights. Shape: (B, seq_len, 1).
+
+ Returns:
+ Denoised output. Shape: (B, C, T, H, W).
+ """
+ if t5xxl_ids is not None:
+ context = self.preprocess_text_embeds(context, t5xxl_ids, t5xxl_weights=t5xxl_weights)
+ return super().forward(x, timesteps, context, **kwargs)
diff --git a/invokeai/backend/anima/anima_transformer_patch.py b/invokeai/backend/anima/anima_transformer_patch.py
new file mode 100644
index 00000000000..4eff79830e9
--- /dev/null
+++ b/invokeai/backend/anima/anima_transformer_patch.py
@@ -0,0 +1,106 @@
+"""Utilities for patching the AnimaTransformer to support regional cross-attention masks."""
+
+from contextlib import contextmanager
+from typing import Optional
+
+import torch
+import torch.nn.functional as F
+from einops import rearrange
+
+from invokeai.backend.anima.regional_prompting import AnimaRegionalPromptingExtension
+
+
+def _patched_cross_attn_forward(
+ original_forward,
+ attn_mask: torch.Tensor,
+):
+ """Create a patched forward for CosmosAttention that injects a cross-attention mask.
+
+ Args:
+ original_forward: The original CosmosAttention.forward method (bound to self).
+ attn_mask: Cross-attention mask of shape (img_seq_len, context_seq_len).
+ """
+
+ def forward(x, context=None, rope_emb=None):
+ # If the context sequence length doesn't match the mask (e.g. negative conditioning
+ # has a different number of tokens than positive regional conditioning), skip masking
+ # and use the original unmasked forward.
+ actual_context = x if context is None else context
+ if actual_context.shape[-2] != attn_mask.shape[1]:
+ return original_forward(x, context, rope_emb=rope_emb)
+
+ self = original_forward.__self__
+
+ q = self.q_proj(x)
+ context = x if context is None else context
+ k = self.k_proj(context)
+ v = self.v_proj(context)
+ q, k, v = (rearrange(t, "b ... (h d) -> b ... h d", h=self.n_heads, d=self.head_dim) for t in (q, k, v))
+
+ q = self.q_norm(q)
+ k = self.k_norm(k)
+ v = self.v_norm(v)
+
+ if self.is_selfattn and rope_emb is not None:
+ from invokeai.backend.anima.anima_transformer import apply_rotary_pos_emb_cosmos
+
+ q = apply_rotary_pos_emb_cosmos(q, rope_emb)
+ k = apply_rotary_pos_emb_cosmos(k, rope_emb)
+
+ in_q_shape = q.shape
+ in_k_shape = k.shape
+ q = rearrange(q, "b ... h d -> b h ... d").reshape(in_q_shape[0], in_q_shape[-2], -1, in_q_shape[-1])
+ k = rearrange(k, "b ... h d -> b h ... d").reshape(in_k_shape[0], in_k_shape[-2], -1, in_k_shape[-1])
+ v = rearrange(v, "b ... h d -> b h ... d").reshape(in_k_shape[0], in_k_shape[-2], -1, in_k_shape[-1])
+
+ # Convert boolean mask to float additive mask for SDPA
+ # True (attend) -> 0.0, False (block) -> -inf
+ # Shape: (img_seq_len, context_seq_len) -> (1, 1, img_seq_len, context_seq_len)
+ float_mask = torch.zeros_like(attn_mask, dtype=q.dtype)
+ float_mask[~attn_mask] = float("-inf")
+ expanded_mask = float_mask.unsqueeze(0).unsqueeze(0)
+
+ result = F.scaled_dot_product_attention(q, k, v, attn_mask=expanded_mask)
+ result = rearrange(result, "b h s d -> b s (h d)")
+ return self.output_dropout(self.output_proj(result))
+
+ return forward
+
+
+@contextmanager
+def patch_anima_for_regional_prompting(
+ transformer,
+ regional_extension: Optional[AnimaRegionalPromptingExtension],
+):
+ """Context manager to temporarily patch the Anima transformer for regional prompting.
+
+ Patches the cross-attention in each DiT block to use a regional attention mask.
+ Uses alternating pattern: masked on even blocks, unmasked on odd blocks for
+ global coherence.
+
+ Args:
+ transformer: The AnimaTransformer instance.
+ regional_extension: The regional prompting extension. If None or no mask, no patching.
+
+ Yields:
+ The (possibly patched) transformer.
+ """
+ if regional_extension is None or regional_extension.cross_attn_mask is None:
+ yield transformer
+ return
+
+ # Store original forwards
+ original_forwards = []
+ for block_idx, block in enumerate(transformer.blocks):
+ original_forwards.append(block.cross_attn.forward)
+
+ mask = regional_extension.get_cross_attn_mask(block_idx)
+ if mask is not None:
+ block.cross_attn.forward = _patched_cross_attn_forward(block.cross_attn.forward, mask)
+
+ try:
+ yield transformer
+ finally:
+ # Restore original forwards
+ for block_idx, block in enumerate(transformer.blocks):
+ block.cross_attn.forward = original_forwards[block_idx]
diff --git a/invokeai/backend/anima/conditioning_data.py b/invokeai/backend/anima/conditioning_data.py
new file mode 100644
index 00000000000..b96c807835d
--- /dev/null
+++ b/invokeai/backend/anima/conditioning_data.py
@@ -0,0 +1,64 @@
+"""Anima text conditioning data structures.
+
+Anima uses a dual-conditioning scheme:
+- Qwen3 0.6B hidden states (continuous embeddings)
+- T5-XXL token IDs (discrete IDs, embedded by the LLM Adapter inside the transformer)
+
+Both are produced by the text encoder invocation and stored together.
+
+For regional prompting, multiple conditionings (each with an optional spatial mask)
+are concatenated and processed together. The LLM Adapter runs on each region's
+conditioning separately, producing per-region context vectors that are concatenated
+for the DiT's cross-attention layers. An attention mask restricts which image tokens
+attend to which regional context tokens.
+"""
+
+from dataclasses import dataclass
+
+import torch
+
+from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Range
+
+
+@dataclass
+class AnimaTextConditioning:
+ """Anima text conditioning with Qwen3 hidden states, T5-XXL token IDs, and optional mask.
+
+ Attributes:
+ qwen3_embeds: Text embeddings from Qwen3 0.6B encoder.
+ Shape: (seq_len, hidden_size) where hidden_size=1024.
+ t5xxl_ids: T5-XXL token IDs for the same prompt.
+ Shape: (seq_len,).
+ t5xxl_weights: Per-token weights for prompt weighting.
+ Shape: (seq_len,). Defaults to all ones if not provided.
+ mask: Optional binary mask for regional prompting. If None, the prompt is global.
+ Shape: (1, 1, img_seq_len) where img_seq_len = (H // patch_size) * (W // patch_size).
+ """
+
+ qwen3_embeds: torch.Tensor
+ t5xxl_ids: torch.Tensor
+ t5xxl_weights: torch.Tensor | None = None
+ mask: torch.Tensor | None = None
+
+
+@dataclass
+class AnimaRegionalTextConditioning:
+ """Container for multiple regional text conditionings processed by the LLM Adapter.
+
+ After the LLM Adapter processes each region's conditioning, the outputs are concatenated.
+ The DiT cross-attention then uses an attention mask to restrict which image tokens
+ attend to which region's context tokens.
+
+ Attributes:
+ context_embeds: Concatenated LLM Adapter outputs from all regional prompts.
+ Shape: (total_context_len, 1024).
+ image_masks: List of binary masks for each regional prompt.
+ If None, the prompt is global (applies to entire image).
+ Shape: (1, 1, img_seq_len).
+ context_ranges: List of ranges indicating which portion of context_embeds
+ corresponds to each regional prompt.
+ """
+
+ context_embeds: torch.Tensor
+ image_masks: list[torch.Tensor | None]
+ context_ranges: list[Range]
diff --git a/invokeai/backend/anima/regional_prompting.py b/invokeai/backend/anima/regional_prompting.py
new file mode 100644
index 00000000000..c0af366332f
--- /dev/null
+++ b/invokeai/backend/anima/regional_prompting.py
@@ -0,0 +1,173 @@
+"""Regional prompting extension for Anima.
+
+Anima's architecture uses separate cross-attention in each DiT block: image tokens
+(in 5D spatial layout) cross-attend to context tokens (LLM Adapter output). This is
+different from Z-Image's unified [img, txt] sequence with self-attention.
+
+For regional prompting, we:
+1. Run the LLM Adapter separately for each regional prompt
+2. Concatenate the resulting context vectors
+3. Build a cross-attention mask that restricts each image region to attend only to
+ its corresponding context tokens
+4. Patch the DiT's cross-attention to use this mask
+
+The mask alternation strategy (masked on even blocks, full on odd blocks) helps
+maintain global coherence across regions.
+"""
+
+from typing import Optional
+
+import torch
+import torchvision
+
+from invokeai.backend.anima.conditioning_data import AnimaRegionalTextConditioning
+from invokeai.backend.util.devices import TorchDevice
+from invokeai.backend.util.mask import to_standard_float_mask
+
+
+class AnimaRegionalPromptingExtension:
+ """Manages regional prompting for Anima's cross-attention.
+
+ Unlike Z-Image which uses a unified [img, txt] sequence, Anima has separate
+ cross-attention where image tokens (query) attend to context tokens (key/value).
+ The cross-attention mask shape is (img_seq_len, context_seq_len).
+ """
+
+ def __init__(
+ self,
+ regional_text_conditioning: AnimaRegionalTextConditioning,
+ cross_attn_mask: torch.Tensor | None = None,
+ ):
+ self.regional_text_conditioning = regional_text_conditioning
+ self.cross_attn_mask = cross_attn_mask
+
+ def get_cross_attn_mask(self, block_index: int) -> torch.Tensor | None:
+ """Get the cross-attention mask for a given block index.
+
+ Uses alternating pattern: apply mask on even blocks, no mask on odd blocks.
+ This helps balance regional control with global coherence.
+ """
+ if block_index % 2 == 0:
+ return self.cross_attn_mask
+ return None
+
+ @classmethod
+ def from_regional_conditioning(
+ cls,
+ regional_text_conditioning: AnimaRegionalTextConditioning,
+ img_seq_len: int,
+ ) -> "AnimaRegionalPromptingExtension":
+ """Create extension from pre-processed regional conditioning.
+
+ Args:
+ regional_text_conditioning: Regional conditioning with concatenated context and masks.
+ img_seq_len: Number of image tokens (H_patches * W_patches).
+ """
+ cross_attn_mask = cls._prepare_cross_attn_mask(regional_text_conditioning, img_seq_len)
+ return cls(
+ regional_text_conditioning=regional_text_conditioning,
+ cross_attn_mask=cross_attn_mask,
+ )
+
+ @classmethod
+ def _prepare_cross_attn_mask(
+ cls,
+ regional_text_conditioning: AnimaRegionalTextConditioning,
+ img_seq_len: int,
+ ) -> torch.Tensor | None:
+ """Prepare a cross-attention mask for regional prompting.
+
+ The mask shape is (img_seq_len, context_seq_len) where:
+ - Each image token can attend to context tokens from its assigned region
+ - Global prompts (mask=None) attend to background regions
+
+ Args:
+ regional_text_conditioning: The regional text conditioning data.
+ img_seq_len: Number of image tokens.
+
+ Returns:
+ Cross-attention mask of shape (img_seq_len, context_seq_len), or None
+ if no regional masks are present.
+ """
+ has_regional_masks = any(mask is not None for mask in regional_text_conditioning.image_masks)
+ if not has_regional_masks:
+ return None
+
+ # Identify background region (area not covered by any mask)
+ background_region_mask: torch.Tensor | None = None
+ for image_mask in regional_text_conditioning.image_masks:
+ if image_mask is not None:
+ mask_flat = image_mask.view(-1)
+ if background_region_mask is None:
+ background_region_mask = torch.ones_like(mask_flat)
+ background_region_mask = background_region_mask * (1 - mask_flat)
+
+ device = TorchDevice.choose_torch_device()
+ context_seq_len = regional_text_conditioning.context_embeds.shape[0]
+
+ # Cross-attention mask: (img_seq_len, context_seq_len)
+ # img tokens are queries, context tokens are keys/values
+ cross_attn_mask = torch.zeros((img_seq_len, context_seq_len), device=device, dtype=torch.float16)
+
+ for image_mask, context_range in zip(
+ regional_text_conditioning.image_masks,
+ regional_text_conditioning.context_ranges,
+ strict=True,
+ ):
+ ctx_start = context_range.start
+ ctx_end = context_range.end
+
+ if image_mask is not None:
+ # Regional prompt: only masked image tokens attend to this region's context
+ mask_flat = image_mask.view(img_seq_len)
+ cross_attn_mask[:, ctx_start:ctx_end] = mask_flat.view(img_seq_len, 1)
+ else:
+ # Global prompt: background image tokens attend to this context
+ if background_region_mask is not None:
+ cross_attn_mask[:, ctx_start:ctx_end] = background_region_mask.view(img_seq_len, 1)
+ else:
+ cross_attn_mask[:, ctx_start:ctx_end] = 1.0
+
+ # Convert to boolean
+ cross_attn_mask = cross_attn_mask > 0.5
+ return cross_attn_mask
+
+ @staticmethod
+ def preprocess_regional_prompt_mask(
+ mask: Optional[torch.Tensor],
+ target_height: int,
+ target_width: int,
+ dtype: torch.dtype,
+ device: torch.device,
+ ) -> torch.Tensor:
+ """Preprocess a regional prompt mask to match the target image token grid.
+
+ Args:
+ mask: Input mask tensor. If None, returns a mask of all ones.
+ target_height: Height of the image token grid (H // patch_size).
+ target_width: Width of the image token grid (W // patch_size).
+ dtype: Target dtype for the mask.
+ device: Target device for the mask.
+
+ Returns:
+ Processed mask of shape (1, 1, target_height * target_width).
+ """
+ img_seq_len = target_height * target_width
+
+ if mask is None:
+ return torch.ones((1, 1, img_seq_len), dtype=dtype, device=device)
+
+ mask = to_standard_float_mask(mask, out_dtype=dtype)
+
+ tf = torchvision.transforms.Resize(
+ (target_height, target_width),
+ interpolation=torchvision.transforms.InterpolationMode.NEAREST,
+ )
+
+ if mask.ndim == 2:
+ mask = mask.unsqueeze(0)
+ if mask.ndim == 3:
+ mask = mask.unsqueeze(0)
+
+ resized_mask = tf(mask)
+ return resized_mask.flatten(start_dim=2).to(device=device)
diff --git a/invokeai/backend/anima/scheduler_driver.py b/invokeai/backend/anima/scheduler_driver.py
new file mode 100644
index 00000000000..854d133011b
--- /dev/null
+++ b/invokeai/backend/anima/scheduler_driver.py
@@ -0,0 +1,150 @@
+"""Anima scheduler driver.
+
+Encapsulates the per-scheduler API quirks that ``anima_denoise._run_diffusion``
+would otherwise have to know about:
+
+* Schedulers that accept ``set_timesteps(sigmas=...)`` get the pre-shifted
+ Anima schedule passed directly.
+* Schedulers that don't accept ``sigmas=`` use ``set_begin_index()`` over their
+ own internal flow-shifted schedule. For Heun, the doubled-array index
+ translation (logical step ``k`` → doubled index ``2k``) is handled here.
+* SDE-style schedulers receive a seeded ``torch.Generator`` on every step.
+
+The denoise loop iterates :meth:`AnimaSchedulerDriver.iterations` and calls
+:meth:`AnimaSchedulerDriver.step` per iteration; the driver yields the
+``sigma_prev`` and ``completes_user_step`` flags the caller needs for inpaint
+mixing and progress reporting.
+"""
+
+from __future__ import annotations
+
+import inspect
+from dataclasses import dataclass
+from typing import Iterator
+
+import torch
+from diffusers import FlowMatchHeunDiscreteScheduler
+from diffusers.schedulers.scheduling_utils import SchedulerMixin
+
+from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP
+
+
+@dataclass(frozen=True)
+class AnimaSchedulerIteration:
+ """Per-iteration metadata yielded by :meth:`AnimaSchedulerDriver.iterations`.
+
+ ``sigma_prev`` is the noise level the latents will be at after this iteration's
+ :meth:`AnimaSchedulerDriver.step` call. ``completes_user_step`` is True when
+ this iteration finishes a user-visible step — for Heun, the second-order
+ half of each pair plus the unpaired terminal first-order step; for every
+ other scheduler, always True.
+ """
+
+ sched_timestep: torch.Tensor
+ sigma_curr: float
+ sigma_prev: float
+ completes_user_step: bool
+ order: int
+
+
+class AnimaSchedulerDriver:
+ """Drives a diffusers scheduler over Anima's pre-shifted sigma schedule."""
+
+ def __init__(
+ self,
+ scheduler_name: str,
+ sigmas: list[float],
+ steps: int,
+ denoising_start: float,
+ denoising_end: float,
+ device: torch.device,
+ seed: int,
+ ):
+ scheduler_class, scheduler_kwargs = ANIMA_SCHEDULER_MAP[scheduler_name]
+ self.scheduler: SchedulerMixin = scheduler_class(num_train_timesteps=1000, **scheduler_kwargs)
+ # Heun toggles state_in_first_order during step(); detect by class so we
+ # can read it before set_timesteps has run.
+ self.is_heun: bool = isinstance(self.scheduler, FlowMatchHeunDiscreteScheduler)
+ self._begin_index: int = 0
+ self._step_generator = torch.Generator(device=device).manual_seed(seed)
+
+ is_lcm = scheduler_name == "lcm"
+ accepts_sigmas = "sigmas" in inspect.signature(self.scheduler.set_timesteps).parameters
+ clipped = denoising_start > 0 or denoising_end < 1
+
+ if not is_lcm and accepts_sigmas:
+ self.scheduler.set_timesteps(sigmas=sigmas, device=device)
+ self._num_iterations = len(self.scheduler.timesteps)
+ elif not is_lcm and clipped and hasattr(self.scheduler, "set_begin_index"):
+ k_start = int(denoising_start * steps)
+ k_end = int(denoising_end * steps)
+ self.scheduler.set_timesteps(num_inference_steps=steps, device=device)
+ if self.is_heun:
+ # Heun's timesteps array is 2N-1 entries; logical step k maps to
+ # doubled index 2k. min() clamps denoising_end=1.0 to the
+ # unpaired terminal first-order step.
+ self._begin_index = 2 * k_start
+ self._num_iterations = min(
+ 2 * (k_end - k_start),
+ len(self.scheduler.timesteps) - self._begin_index,
+ )
+ else:
+ self._begin_index = k_start
+ self._num_iterations = k_end - self._begin_index
+ self.scheduler.set_begin_index(self._begin_index)
+ else:
+ self.scheduler.set_timesteps(num_inference_steps=len(sigmas) - 1, device=device)
+ self._num_iterations = len(self.scheduler.timesteps)
+
+ @property
+ def num_iterations(self) -> int:
+ """Total :meth:`step` calls. For Heun this is roughly 2× the user-visible step count."""
+ return self._num_iterations
+
+ @property
+ def begin_index(self) -> int:
+ return self._begin_index
+
+ def iterations(self) -> Iterator[AnimaSchedulerIteration]:
+ for i in range(self._num_iterations):
+ sched_idx = i + self._begin_index
+ sched_timestep = self.scheduler.timesteps[sched_idx]
+ sigma_curr = sched_timestep.item() / self.scheduler.config.num_train_timesteps
+
+ # Read state_in_first_order before step (Heun toggles it inside step()).
+ in_first_order = self.scheduler.state_in_first_order if self.is_heun else True
+
+ next_idx = sched_idx + 1
+ sigma_prev = self.scheduler.sigmas[next_idx].item() if next_idx < len(self.scheduler.sigmas) else 0.0
+
+ # For Heun, a user step completes on the second-order half of each
+ # pair AND on the unpaired terminal first-order step (sigma_prev==0).
+ is_terminal = sigma_prev == 0.0
+ completes_user_step = (not self.is_heun) or (not in_first_order) or is_terminal
+ order = 2 if self.is_heun else 1
+
+ yield AnimaSchedulerIteration(
+ sched_timestep=sched_timestep,
+ sigma_curr=sigma_curr,
+ sigma_prev=sigma_prev,
+ completes_user_step=completes_user_step,
+ order=order,
+ )
+
+ def step(
+ self,
+ model_output: torch.Tensor,
+ timestep: torch.Tensor,
+ sample: torch.Tensor,
+ ) -> torch.Tensor:
+ step_output = self.scheduler.step(
+ model_output=model_output,
+ timestep=timestep,
+ sample=sample,
+ generator=self._step_generator,
+ )
+ return step_output.prev_sample
+
+ @property
+ def step_generator(self) -> torch.Generator:
+ return self._step_generator
diff --git a/invokeai/backend/anima/t5_tokenizer.py b/invokeai/backend/anima/t5_tokenizer.py
new file mode 100644
index 00000000000..234a574c3e0
--- /dev/null
+++ b/invokeai/backend/anima/t5_tokenizer.py
@@ -0,0 +1,25 @@
+"""Bundled T5-XXL tokenizer for Anima.
+
+Anima tokenizes the prompt with the T5-XXL tokenizer to produce token IDs that
+index the LLM Adapter's learned embedding table. Only the tokenizer is needed —
+never the 9GB T5-XXL weights — so the tokenizer is vendored in the package as a
+self-contained fast tokenizer (tokenizer.json), avoiding both the large download
+and the sentencepiece runtime path.
+"""
+
+from functools import lru_cache
+from pathlib import Path
+
+from transformers import T5TokenizerFast
+
+# Size of the LLM Adapter's token embedding table (T5 v1.1 vocab incl. 100 sentinel
+# extra_id tokens). Token IDs must stay within this range.
+ANIMA_T5_VOCAB_SIZE = 32128
+
+_TOKENIZER_DIR = Path(__file__).parent / "tokenizer"
+
+
+@lru_cache(maxsize=1)
+def load_bundled_t5_tokenizer() -> T5TokenizerFast:
+ """Load the vendored T5-XXL fast tokenizer. Result is cached for the process."""
+ return T5TokenizerFast.from_pretrained(_TOKENIZER_DIR)
diff --git a/invokeai/backend/anima/tokenizer/special_tokens_map.json b/invokeai/backend/anima/tokenizer/special_tokens_map.json
new file mode 100644
index 00000000000..17ade346a10
--- /dev/null
+++ b/invokeai/backend/anima/tokenizer/special_tokens_map.json
@@ -0,0 +1,125 @@
+{
+ "additional_special_tokens": [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ],
+ "eos_token": {
+ "content": "",
+ "lstrip": false,
+ "normalized": false,
+ "rstrip": false,
+ "single_word": false
+ },
+ "pad_token": {
+ "content": "",
+ "lstrip": false,
+ "normalized": false,
+ "rstrip": false,
+ "single_word": false
+ },
+ "unk_token": {
+ "content": "",
+ "lstrip": false,
+ "normalized": false,
+ "rstrip": false,
+ "single_word": false
+ }
+}
diff --git a/invokeai/backend/anima/tokenizer/tokenizer.json b/invokeai/backend/anima/tokenizer/tokenizer.json
new file mode 100644
index 00000000000..21ed409afa3
--- /dev/null
+++ b/invokeai/backend/anima/tokenizer/tokenizer.json
@@ -0,0 +1,129428 @@
+{
+ "version": "1.0",
+ "truncation": null,
+ "padding": null,
+ "added_tokens": [
+ {
+ "id": 0,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 1,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 2,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32000,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32001,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32002,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32003,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32004,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32005,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32006,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32007,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32008,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32009,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32010,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32011,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32012,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32013,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32014,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32015,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32016,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32017,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32018,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32019,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32020,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32021,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32022,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32023,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32024,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32025,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32026,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32027,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32028,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32029,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32030,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32031,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32032,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32033,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32034,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32035,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32036,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32037,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32038,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32039,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32040,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32041,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32042,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32043,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32044,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32045,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32046,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32047,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32048,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32049,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32050,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32051,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32052,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32053,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32054,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32055,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32056,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32057,
+ "content": "",
+ "single_word": false,
+ "lstrip": false,
+ "rstrip": false,
+ "normalized": false,
+ "special": true
+ },
+ {
+ "id": 32058,
+ "content": "